Compare commits
10 Commits
ff5c98118a
...
0d10ab8110
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d10ab8110 | |||
| 373493ca2f | |||
| 4bd1ef77e1 | |||
| acbddba607 | |||
| f4e72c38d6 | |||
| 5e154f8a98 | |||
| 610c36287d | |||
| dce2d0043f | |||
| 6cb48ae758 | |||
| a6df81c19e |
@@ -1,16 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Obtener el directorio donde está el script
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
cd "$DIR"
|
||||
|
||||
# Comprobar si se está ejecutando en una terminal
|
||||
if [ -t 0 ]; then
|
||||
# Estamos en una terminal, pedir sudo directamente
|
||||
echo "Iniciando JNC-Scan..."
|
||||
sudo ./venv/bin/python3 main.py
|
||||
else
|
||||
# No estamos en una terminal (doble clic), intentar abrir una
|
||||
if command -v gnome-terminal &> /dev/null; then
|
||||
gnome-terminal -- bash -c "sudo $DIR/venv/bin/python3 main.py; exec bash"
|
||||
elif command -v x-terminal-emulator &> /dev/null; then
|
||||
@@ -18,7 +11,6 @@ else
|
||||
elif command -v xterm &> /dev/null; then
|
||||
xterm -e "sudo $DIR/venv/bin/python3 main.py"
|
||||
else
|
||||
# Fallback gráfico si no hay terminal detectada (usando pkexec si existe)
|
||||
if command -v pkexec &> /dev/null; then
|
||||
pkexec $DIR/venv/bin/python3 main.py
|
||||
else
|
||||
|
||||
@@ -1,79 +1,99 @@
|
||||
JNC-NetTools // Versión ADA - Documentación Técnica
|
||||
# JNC-NetTools // Versión ADA - Documentación Técnica
|
||||
|
||||
## 1. Introducción
|
||||
|
||||
1. Introducción
|
||||
JNC-NetTools es una suite integral de diagnóstico y gestión de redes diseñada para sistemas Linux . Proporciona una interfaz gráfica de usuario (GUI) para tareas de red avanzadas que normalmente requieren interacciones complejas en la línea de comandos.
|
||||
Capacidades Clave:
|
||||
• Gestión de Interfaces de Red (IP, Subred, Puerta de Enlace).
|
||||
• Bridging Avanzado (Trunks VLAN, Passthrough, Wi-Fi a Ethernet).
|
||||
• Escaneo de Redes y Puertos (Integración con Nmap).
|
||||
• Análisis de Tráfico y Sniffing de Paquetes (Integración con Scapy).
|
||||
• Diagnóstico de Red (Ping, Traceroute, DNS).
|
||||
• Generación Automatizada de Informes (HTML).
|
||||
JNC-NetTools es una suite integral de diagnóstico y gestión de redes diseñada para sistemas Linux. Proporciona una interfaz gráfica de usuario (GUI) para tareas de red avanzadas que normalmente requieren interacciones complejas en la línea de comandos.
|
||||
|
||||
2. Requisitos del Sistema
|
||||
Sistema Operativo
|
||||
• Linux (Probado en Linux Mint / Ubuntu / Debian).
|
||||
• Privilegios de Root: Requeridos para la mayoría de las operaciones (manipulación de interfaces, escaneo de sockets raw, sniffing).
|
||||
Dependencias de Software
|
||||
La aplicación depende de herramientas de red estándar de Linux y bibliotecas de Python:
|
||||
• Python: 3.8 o superior.
|
||||
• Herramientas del Sistema:
|
||||
◦ iproute2 (comando ip)
|
||||
◦ nmap (Mapeador de Redes)
|
||||
◦ tcpdump (Captura de paquetes)
|
||||
◦ dnsmasq (Servidor DHCP)
|
||||
◦ hostapd (Punto de Acceso Wi-Fi)
|
||||
◦ iw (Configuración inalámbrica)
|
||||
◦ ethtool (Estadísticas de interfaz - opcional)
|
||||
Bibliotecas de Python
|
||||
• PyQt6: Framework GUI.
|
||||
• python-nmap: Wrapper de Python para Nmap.
|
||||
• scapy: Manipulación y sniffing de paquetes.
|
||||
• psutil: Monitoreo del sistema.
|
||||
• pyroute2: Redes avanzadas en Linux (Netlink).
|
||||
### Capacidades Clave
|
||||
- Gestión de Interfaces de Red (IP, Subred, Puerta de Enlace)
|
||||
- Bridging Avanzado (Trunks VLAN, Passthrough, Wi-Fi a Ethernet)
|
||||
- Escaneo de Redes y Puertos (Integración con Nmap)
|
||||
- Análisis de Tráfico y Sniffing de Paquetes (Integración con Scapy)
|
||||
- Diagnóstico de Red (Ping, Traceroute, DNS)
|
||||
- Generación Automatizada de Informes (HTML)
|
||||
|
||||
3. Instalación y Ejecución
|
||||
Instalación
|
||||
1. Clonar el repositorio:
|
||||
git clone https://github.com/inlutec/JNC-NetTools.git
|
||||
cd JNC-Scan
|
||||
2. Instalar Dependencias del Sistema:
|
||||
sudo apt update
|
||||
sudo apt install python3-pip nmap tcpdump dnsmasq hostapd iw
|
||||
3. Configurar el Entorno Python:
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
(Si falta
|
||||
requirements.txt, instale manualmente: pip install PyQt6 python-nmap scapy psutil pyroute2)
|
||||
Ejecución
|
||||
Para ejecutar la aplicación, utilice el script de lanzamiento proporcionado que gestiona los permisos de root y la activación del entorno virtual:
|
||||
## 2. Requisitos del Sistema
|
||||
|
||||
### Sistema Operativo
|
||||
- Linux (Probado en Linux Mint / Ubuntu / Debian)
|
||||
- Privilegios de Root: Requeridos para la mayoría de las operaciones (manipulación de interfaces, escaneo de sockets raw, sniffing)
|
||||
|
||||
### Dependencias de Software
|
||||
|
||||
#### Herramientas del Sistema
|
||||
| Herramienta | Descripción | Obligatoria |
|
||||
|---------------|------------------------------------------|-------------|
|
||||
| iproute2 | Comando `ip` | Sí |
|
||||
| nmap | Mapeador de redes | Sí |
|
||||
| tcpdump | Captura de paquetes | Sí |
|
||||
| dnsmasq | Servidor DHCP | Sí |
|
||||
| hostapd | Punto de acceso Wi-Fi | Sí |
|
||||
| iw | Configuración inalámbrica | Sí |
|
||||
| ethtool | Estadísticas de interfaz | Opcional |
|
||||
|
||||
#### Bibliotecas de Python
|
||||
| Biblioteca | Uso Principal |
|
||||
|----------------|----------------------------------------|
|
||||
| PyQt6 | Framework GUI |
|
||||
| python-nmap | Wrapper de Nmap |
|
||||
| scapy | Manipulación y sniffing de paquetes |
|
||||
| psutil | Monitoreo del sistema |
|
||||
| pyroute2 | Redes avanzadas en Linux (Netlink) |
|
||||
|
||||
- Python: 3.8 o superior
|
||||
|
||||
## 3. Instalación y Ejecución
|
||||
|
||||
### Instalación
|
||||
|
||||
```bash
|
||||
# 1. Clonar el repositorio
|
||||
git clone https://github.com/inlutec/JNC-NetTools.git
|
||||
cd JNC-NetTools
|
||||
|
||||
# 2. Instalar dependencias del sistema
|
||||
sudo apt update
|
||||
sudo apt install python3-pip nmap tcpdump dnsmasq hostapd iw
|
||||
|
||||
# 3. Configurar entorno virtual
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Si no existe requirements.txt, instalar manualmente:
|
||||
# pip install PyQt6 python-nmap scapy psutil pyroute2
|
||||
```
|
||||
|
||||
### Ejecución
|
||||
|
||||
```bash
|
||||
bash JNC-Scan_Launcher.sh
|
||||
Nota: Se le solicitará su contraseña de sudo.
|
||||
```
|
||||
|
||||
> Nota: El launcher gestiona automáticamente la activación del entorno virtual y los privilegios de root (se solicitará la contraseña sudo).
|
||||
|
||||
4. Arquitectura Técnica
|
||||
La aplicación sigue una arquitectura modular que separa la Lógica del Núcleo (Backend) de la GUI (Frontend).
|
||||
Estructura de Directorios
|
||||
JNC-Scan/
|
||||
## 4. Arquitectura Técnica
|
||||
|
||||
La aplicación sigue una arquitectura modular que separa claramente la lógica del núcleo (backend) de la interfaz gráfica (frontend).
|
||||
|
||||
### Estructura de Directorios
|
||||
|
||||
|
||||
├── main.py # Punto de entrada
|
||||
├── JNC-Scan_Launcher.sh # Script de inicio
|
||||
```
|
||||
JNC-NetTools/
|
||||
├── main.py # Punto de entrada principal
|
||||
├── JNC-Scan_Launcher.sh # Script de lanzamiento con sudo
|
||||
├── requirements.txt # Dependencias Python (si existe)
|
||||
├── src/
|
||||
│ ├── core/ # Lógica Backend
|
||||
│ │ ├── network_manager.py # Lógica de Interfaz, Bridge, DHCP, Wi-Fi
|
||||
│ │ ├── scanner.py # Lógica de Escáner de Puertos/Red Nmap
|
||||
│ │ ├── sniffer.py # Lógica de Sniffer Scapy
|
||||
│ │ ├── vlan_scanner.py # Detección pasiva de VLAN (tcpdump)
|
||||
│ │ └── diagnostics.py # Ping, Traceroute, etc.
|
||||
│ ├── gui/ # Frontend (PyQt6)
|
||||
│ │ ├── main_window.py # Ventana principal de la aplicación
|
||||
│ │ ├── styles.py # Hojas de estilo CSS/QSS
|
||||
│ │ └── widgets/ # Pestañas de funciones individuales
|
||||
│ ├── core/ # Lógica Backend
|
||||
│ │ ├── network_manager.py # Gestión de interfaces, bridges, DHCP, Wi-Fi AP
|
||||
│ │ ├── scanner.py # Escáner de red y puertos (Nmap)
|
||||
│ │ ├── sniffer.py # Sniffer con Scapy
|
||||
│ │ ├── vlan_scanner.py # Detección pasiva de VLANs (tcpdump)
|
||||
│ │ └── diagnostics.py # Ping, traceroute, DNS lookup
|
||||
│ ├── gui/ # Frontend PyQt6
|
||||
│ │ ├── main_window.py # Ventana principal y pestañas
|
||||
│ │ ├── styles.py # Hojas de estilo QSS
|
||||
│ │ └── widgets/ # Widgets por funcionalidad
|
||||
│ │ ├── ip_config.py
|
||||
│ │ ├── vlan_bridge.py
|
||||
│ │ ├── scanner_view.py
|
||||
@@ -81,90 +101,75 @@ JNC-Scan/
|
||||
│ │ ├── sniffer_view.py
|
||||
│ │ └── diagnostics_view.py
|
||||
│ └── utils/
|
||||
│ └── report_generator.py # Generación de Informes HTML
|
||||
│ └── report_generator.py # Generación de informes HTML
|
||||
```
|
||||
|
||||
## 5. Funcionalidades Detalladas e Implementación Técnica
|
||||
|
||||
### A. Configuración IP
|
||||
- Uso de `psutil` + `pyroute2` para lectura
|
||||
- Aplicación persistente mediante `nmcli` (NetworkManager)
|
||||
- Refresco en tiempo real de interfaces
|
||||
|
||||
### B. VLAN y Bridging Avanzado
|
||||
- Creación de VLANs: `ip link add link <dev> name <dev.id> type vlan id <id>`
|
||||
- Bridges: `ip link add name <br> type bridge`
|
||||
- Servidor DHCP integrado con `dnsmasq`
|
||||
- AP Wi-Fi con `hostapd` + configuración dinámica
|
||||
- Soporte automático para modo 4addr (WDS) en interfaces Wi-Fi cliente
|
||||
- Escáner pasivo de VLANs mediante `tcpdump -e` + regex 802.1Q
|
||||
|
||||
5. Funcionalidades Detalladas e Implementación Técnica
|
||||
A. Configuración IP
|
||||
• Función: Ver y configurar direcciones IP, subredes y puertas de enlace.
|
||||
• Implementación:
|
||||
◦ Utiliza psutil y pyroute2 para obtener el estado de la interfaz.
|
||||
◦ Utiliza nmcli (CLI de NetworkManager) para aplicar configuraciones persistentes (IP Estática o DHCP).
|
||||
◦ Refresco: Actualizaciones en tiempo real de la lista de interfaces.
|
||||
B. VLAN y Bridging
|
||||
• Función: Crear puentes de red complejos.
|
||||
◦ VLAN Trunk: Puentea un ID de VLAN específico desde un puerto trunk a un puerto de acceso.
|
||||
◦ Passthrough: Puentea transparentemente dos interfaces.
|
||||
◦ Bridging Wi-Fi AP: Crea un punto de acceso Wi-Fi puenteado a una red cableada.
|
||||
• Detalles Técnicos:
|
||||
◦ VLANs: Creadas usando ip link add link <dev> name <dev.id> type vlan id <id>.
|
||||
◦ Bridges: Creados usando ip link add name <br> type bridge.
|
||||
◦ Servidor DHCP: Lanza un proceso dnsmasq vinculado a la interfaz del puente para servir direcciones IP a los clientes conectados.
|
||||
◦ Wi-Fi AP: Genera un hostapd.conf temporal y lanza hostapd.
|
||||
◦ Bridging de Cliente Wi-Fi (Corrección Error 95): Intenta automáticamente habilitar el modo 4addr (WDS) usando iw dev <iface> set 4addr on para permitir que los clientes Wi-Fi sean puenteados. Maneja los errores EOPNOTSUPP con elegancia.
|
||||
◦ Escáner Pasivo de VLAN: Utiliza tcpdump -e para capturar cabeceras y análisis regex para detectar etiquetas 802.1Q en un puerto trunk sin unirse a la VLAN.
|
||||
### C. Escáner de Red (Host Discovery)
|
||||
- Motor: Nmap
|
||||
- Prioriza escaneo ARP en redes locales
|
||||
- Soporte completo para detener escaneo en ejecución
|
||||
- Filtros en tiempo real (Up/Down)
|
||||
|
||||
### D. Escáner de Puertos
|
||||
- Modos: TCP Connect (`-sT`), UDP (`-sU`), o ambos
|
||||
- Optimizado con `--min-rate 1000 -T4`
|
||||
- Oculta automáticamente puertos cerrados en rangos grandes (>500)
|
||||
- Ejecutado en `QThread` para mantener GUI receptiva
|
||||
|
||||
### E. Sniffer de Paquetes
|
||||
- Motor: `scapy.sniff`
|
||||
- Soporte completo para filtros BPF
|
||||
- Decodificación de capas Ethernet/IP/TCP/UDP/ICMP
|
||||
- Detección automática de anomalías (RST, ICMP Unreachable, retransmisiones)
|
||||
- Vista separada de "Todo el tráfico" vs "Solo anomalías"
|
||||
|
||||
C. Escáner de Red (Descubrimiento)
|
||||
• Función: Descubre dispositivos activos en un rango de red (CIDR).
|
||||
• Implementación:
|
||||
◦ Motor: nmap.
|
||||
◦ Método: Escaneo ARP/Ping (lógica equivalente a -sn, pero usamos -sT con skip_discovery=False para asegurar verificación activa).
|
||||
◦ Optimización: Prioriza ARP para redes locales para mayor velocidad.
|
||||
◦ Características: Registro en tiempo real, capacidad de "Detener Escaneo", Filtros de Estado (Up/Down).
|
||||
D. Escáner de Puertos
|
||||
• Función: Análisis detallado de una IP objetivo específica.
|
||||
• Implementación:
|
||||
◦ Motor: nmap.
|
||||
◦ Modos: TCP Connect (-sT), UDP (-sU), o Ambos.
|
||||
◦ Rendimiento: Utiliza --min-rate 1000 y -T4 para velocidad.
|
||||
◦ Rangos Grandes: Oculta automáticamente los puertos "Cerrados" si se escanean >500 puertos para evitar congelamientos de la UI.
|
||||
◦ Hilos: Se ejecuta en un QThread para mantener la GUI receptiva.
|
||||
E. Sniffer de Paquetes
|
||||
• Función: Análisis de tráfico en tiempo real.
|
||||
• Implementación:
|
||||
◦ Motor: scapy.sniff.
|
||||
◦ Filtros: Soporta BPF (ej. tcp port 80) y Filtro IP Personalizado (post-filtro basado en GUI).
|
||||
◦ Análisis:
|
||||
▪ Decodifica capas Ethernet, IP, TCP, UDP, ICMP.
|
||||
▪ Detección de Anomalías: Resalta banderas "RST" (Reinicio de Conexión), errores ICMP Unreachable y Retransmisiones.
|
||||
▪ Vista Dividida: Separa "Todo el Tráfico" de "Anomalías" para una depuración más fácil.
|
||||
### F. Diagnósticos
|
||||
- Wrappers con análisis de salida para: `ping`, `traceroute`, `nslookup`
|
||||
|
||||
### G. Generación de Informes
|
||||
- Informe HTML completo con todos los resultados de la sesión
|
||||
- Estilizado y con timestamps
|
||||
|
||||
## 6. Guía para Desarrolladores
|
||||
|
||||
F. Diagnósticos
|
||||
• Función: Herramientas de conectividad estándar.
|
||||
• Implementación: Wrappers alrededor de comandos del sistema (ping, traceroute, nslookup) con análisis de salida para mostrar en un área de texto de la GUI.
|
||||
G. Informes
|
||||
• Función: Genera un informe HTML de la sesión actual.
|
||||
• Implementación: Recopila datos de todos los widgets (resultados de escaneo, registros del sniffer) y los compila en un archivo HTML estilizado utilizando formato de cadenas de Python.
|
||||
### Añadir una nueva pestaña
|
||||
1. Crear widget en `src/gui/widgets/nueva_herramienta.py`
|
||||
2. Heredar de `QWidget`
|
||||
3. Importar en `src/gui/main_window.py`
|
||||
4. Añadir: `self.tabs.addTab(NuevaHerramienta(), "Nueva Herramienta")`
|
||||
|
||||
6. Guía para Desarrolladores (Modificando el Código)
|
||||
Añadir una Nueva Pestaña
|
||||
1. Cree un nuevo archivo de widget en src/gui/widgets/ (ej. mi_herramienta.py).
|
||||
2. Defina una clase que herede de QWidget.
|
||||
3. Impórtela en src/gui/main_window.py.
|
||||
4. Añádala a las pestañas: self.tabs.addTab(MiHerramienta(), "Mi Herramienta").
|
||||
Modificar Escaneos Nmap
|
||||
• Edite src/core/scanner.py.
|
||||
• El método scan() construye los argumentos del comando Nmap. Puede añadir banderas como -O (Detección de SO) o -sV (Detección de Versión) aquí.
|
||||
Personalizar el Sniffer
|
||||
• Edite src/core/sniffer.py.
|
||||
• La función process_packet determina cómo se analizan los paquetes. Puede añadir lógica para detectar payloads específicos (ej. cabeceras HTTP, firmas de malware específicas).
|
||||
Estilos
|
||||
• Edite src/gui/styles.py.
|
||||
• La aplicación utiliza QSS (Hojas de Estilo Qt). Puede cambiar colores, fuentes y comportamientos de los widgets aquí.
|
||||
### Modificar escaneos Nmap
|
||||
- Editar `src/core/scanner.py` → método `scan()`
|
||||
- Añadir flags como `-O` (detección SO) o `-sV` (versiones)
|
||||
|
||||
### Personalizar el sniffer
|
||||
- Editar `src/core/sniffer.py` → función `process_packet`
|
||||
- Añadir detección de payloads específicos (HTTP, malware, etc.)
|
||||
|
||||
### Cambiar estilos
|
||||
- Editar `src/gui/styles.py` (QSS)
|
||||
|
||||
## 7. Solución de Problemas Comunes
|
||||
|
||||
| Problema | Causa más común | Solución |
|
||||
|---------------------------------------|----------------------------------------------|--------------------------------------------------------------------------|
|
||||
| "Operation not supported" (Error 95) | Wi-Fi no soporta modo 4addr/WDS | Usar tarjeta compatible o modo "Wi-Fi AP" en lugar de puente cliente |
|
||||
| `iw: command not found` | Herramienta `iw` no instalada | `sudo apt install iw` |
|
||||
| Sin resultados en escaneos | No ejecutado como root o firewall bloqueando | Usar el launcher oficial / desactivar temporalmente ufw |
|
||||
| UI se congela en sniffer | Volumen de tráfico muy alto | Aplicar filtro BPF estricto (ej. `tcp port 80`) |
|
||||
|
||||
7. Solución de Problemas
|
||||
• "Operation not supported" (Error 95): Ocurre al puentear una interfaz Wi-Fi que no soporta el modo de 4 direcciones.
|
||||
◦ Solución: Use una tarjeta Wi-Fi que soporte modo WDS/Mesh, o use el modo "Wi-Fi AP" en lugar de puentear una conexión cliente.
|
||||
• "iw: command not found": Falta la herramienta iw en el sistema.
|
||||
◦ Solución: sudo apt install iw.
|
||||
• Sin Resultados de Escaneo:
|
||||
◦ Asegúrese de estar ejecutando como root (a través del lanzador).
|
||||
◦ Verifique si el firewall (ufw) está bloqueando las respuestas.
|
||||
• Congelamiento de UI:
|
||||
◦ Un tráfico extremadamente alto en el Sniffer puede ralentizar la UI. Use el filtro BPF para reducir el volumen de captura.
|
||||
|
||||
@@ -9,9 +9,6 @@ def main():
|
||||
if os.geteuid() != 0:
|
||||
print("This application requires root privileges to manage network interfaces.")
|
||||
print("Please run with sudo.")
|
||||
# In a real GUI app we might want to show a dialog before exiting,
|
||||
# but for now we'll just print to stdout.
|
||||
# We can also attempt to restart with sudo, but that's complex for now.
|
||||
sys.exit(1)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,996 @@
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from pyroute2 import IPRoute
|
||||
|
||||
from src.core.network_manager import NetworkManager
|
||||
|
||||
|
||||
class AdvancedRouter:
|
||||
"""
|
||||
Capa de alto nivel sobre pyroute2 / iptables / nftables
|
||||
para ofrecer primitivas de routing avanzadas al GUI.
|
||||
"""
|
||||
|
||||
def __init__(self, network_manager: NetworkManager):
|
||||
self.nm = network_manager
|
||||
# Reutilizamos el mismo IPRoute que usa NetworkManager
|
||||
self.ipr: IPRoute = network_manager.ipr
|
||||
|
||||
# Estado en memoria para poder mostrar en la UI y guardar/cargar
|
||||
self.vlans: Dict[str, Dict] = {} # clave: ifname VLAN (ej. eth0.10)
|
||||
self.vlan_redirections: List[Dict] = []
|
||||
self.dhcp_servers: Dict[str, Dict] = {} # clave: interfaz sobre la que corre dnsmasq
|
||||
self.gateways: Dict[str, Dict] = {} # clave: nombre interfaz o VLAN ifname
|
||||
self.routes: List[Dict] = []
|
||||
self.routing_rules: List[Dict] = []
|
||||
self.nat_rules: List[Dict] = []
|
||||
self.firewall_rules: List[Dict] = []
|
||||
self.vlan_visibility: Dict[Tuple[int, int], bool] = {} # (src_vlan, dst_vlan) -> allowed
|
||||
|
||||
self.firewall_backend = self.detect_firewall_backend()
|
||||
|
||||
# Descubrir VLANs existentes en el sistema (por ejemplo eth0.10 creadas en sesiones anteriores)
|
||||
self._discover_existing_vlans()
|
||||
|
||||
def _discover_existing_vlans(self) -> None:
|
||||
"""
|
||||
Recorre los enlaces de pyroute2 y añade a self.vlans
|
||||
todas las interfaces cuyo nombre tenga formato base.VLAN
|
||||
(ej: enp1s0.10). Esto permite que VLANs creadas en una
|
||||
sesión anterior se muestren y puedan borrarse.
|
||||
"""
|
||||
try:
|
||||
links = self.ipr.get_links()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for link in links:
|
||||
ifname = link.get_attr("IFLA_IFNAME")
|
||||
if not ifname:
|
||||
continue
|
||||
|
||||
if "." not in ifname:
|
||||
continue
|
||||
|
||||
base, dot, vlan_str = ifname.partition(".")
|
||||
try:
|
||||
vlan_id = int(vlan_str)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if vlan_id < 1 or vlan_id > 4094:
|
||||
continue
|
||||
|
||||
# No machacamos una definición existente en memoria (por si viene de un load_config)
|
||||
if ifname in self.vlans:
|
||||
continue
|
||||
|
||||
self.vlans[ifname] = {
|
||||
"parent": base,
|
||||
"vlan_id": vlan_id,
|
||||
"mode": "trunk",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VLANs y Trunks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_vlan_trunk(self, interface: str, vlan_ids: List[int]) -> Tuple[bool, str]:
|
||||
"""
|
||||
Crea múltiples interfaces VLAN tagged sobre una interfaz trunk.
|
||||
No crea bridges todavía; eso se hará desde otras primitivas.
|
||||
"""
|
||||
try:
|
||||
trunk_idx = self.ipr.link_lookup(ifname=interface)[0]
|
||||
except IndexError:
|
||||
return False, f"Interface {interface} not found"
|
||||
|
||||
# Verificar que la interfaz esté UP antes de crear VLANs
|
||||
try:
|
||||
link_info = self.ipr.get_links(ifname=interface)[0]
|
||||
operstate = link_info.get_attr("IFLA_OPERSTATE")
|
||||
if operstate != "UP":
|
||||
return False, f"La interfaz {interface} debe estar UP antes de crear VLANs. Estado actual: {operstate}"
|
||||
except Exception:
|
||||
pass # Si no podemos verificar el estado, continuamos de todas formas
|
||||
|
||||
created = []
|
||||
for vid in vlan_ids:
|
||||
if vid < 1 or vid > 4094:
|
||||
return False, f"Invalid VLAN ID {vid}"
|
||||
|
||||
vlan_ifname = f"{interface}.{vid}"
|
||||
try:
|
||||
# Si ya existe, simplemente la marcamos como conocida
|
||||
if self.ipr.link_lookup(ifname=vlan_ifname):
|
||||
self.vlans[vlan_ifname] = {
|
||||
"parent": interface,
|
||||
"vlan_id": vid,
|
||||
"mode": "trunk",
|
||||
}
|
||||
created.append(vlan_ifname)
|
||||
continue
|
||||
|
||||
# Asegurar que vlan_id es un entero válido en el rango correcto
|
||||
vlan_id_int = int(vid)
|
||||
if vlan_id_int < 1 or vlan_id_int > 4094:
|
||||
return False, f"VLAN ID {vid} fuera de rango válido (1-4094)"
|
||||
|
||||
# Crear la VLAN usando la misma sintaxis que network_manager
|
||||
self.ipr.link(
|
||||
"add",
|
||||
ifname=vlan_ifname,
|
||||
kind="vlan",
|
||||
link=trunk_idx,
|
||||
vlan_id=vlan_id_int,
|
||||
)
|
||||
vlan_idx = self.ipr.link_lookup(ifname=vlan_ifname)[0]
|
||||
self.ipr.link("set", index=vlan_idx, state="up")
|
||||
|
||||
self.vlans[vlan_ifname] = {
|
||||
"parent": interface,
|
||||
"vlan_id": vid,
|
||||
"mode": "trunk",
|
||||
}
|
||||
created.append(vlan_ifname)
|
||||
except IndexError:
|
||||
# La interfaz no se creó correctamente
|
||||
return False, f"No se pudo crear la interfaz VLAN {vlan_ifname}. Verifica que la interfaz base {interface} soporte VLANs."
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
# Mejorar el mensaje de error para casos comunes
|
||||
if "34" in error_msg or "out of range" in error_msg.lower() or "ERANGE" in error_msg:
|
||||
# Obtener lista de interfaces disponibles que podrían soportar VLANs
|
||||
available_ifaces = [iface["name"] for iface in self.nm.list_interfaces()
|
||||
if iface["name"] != interface and iface["type"] == "Wired"]
|
||||
suggestions = ""
|
||||
if available_ifaces:
|
||||
suggestions = f"\n\nInterfaces alternativas disponibles que podrían soportar VLANs:\n" + ", ".join(available_ifaces[:5])
|
||||
|
||||
return False, (
|
||||
f"❌ La interfaz '{interface}' NO soporta VLAN tagging 802.1Q.\n\n"
|
||||
f"Motivo: El driver de esta interfaz (probablemente USB Ethernet) no implementa "
|
||||
f"soporte para VLANs a nivel de kernel.\n\n"
|
||||
f"Soluciones:\n"
|
||||
f"• Usa una interfaz Ethernet tradicional (no USB) como eth0, enp*, etc.\n"
|
||||
f"• Si necesitas conectar redes, usa la pestaña 'VLAN / Puente' para crear "
|
||||
f"bridges simples sin VLAN tagging.\n"
|
||||
f"• Verifica el soporte de VLANs con: 'cat /proc/net/vlan/config'"
|
||||
f"{suggestions}"
|
||||
)
|
||||
return False, f"Error creating VLAN {vid} on {interface}: {error_msg}"
|
||||
|
||||
return True, f"Created/validated VLANs on {interface}: {', '.join(created)}"
|
||||
|
||||
def create_vlan_access(self, interface: str, vlan_id: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
Configura una interfaz como acceso untagged a una VLAN.
|
||||
A nivel Linux esto normalmente se modela como bridge + VLAN filter.
|
||||
Aquí simplemente registramos el mapeo lógico; el bridge real se
|
||||
gestiona en create_bridge_with_vlan_filters().
|
||||
"""
|
||||
if vlan_id < 1 or vlan_id > 4094:
|
||||
return False, "Invalid VLAN ID"
|
||||
|
||||
self.vlans[interface] = {
|
||||
"parent": interface,
|
||||
"vlan_id": vlan_id,
|
||||
"mode": "access",
|
||||
}
|
||||
return True, f"Interface {interface} marked as access for VLAN {vlan_id}"
|
||||
|
||||
def delete_vlan(self, vlan_ifname: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Elimina una interfaz VLAN del sistema y limpia su estado interno.
|
||||
También limpia redirecciones y configuraciones asociadas.
|
||||
"""
|
||||
try:
|
||||
# Verificar si existe en el sistema
|
||||
vlan_idx = self.ipr.link_lookup(ifname=vlan_ifname)
|
||||
if vlan_idx:
|
||||
# Primero bajar la interfaz
|
||||
self.ipr.link("set", index=vlan_idx[0], state="down")
|
||||
# Luego eliminarla
|
||||
self.ipr.link("del", index=vlan_idx[0])
|
||||
|
||||
# Limpiar del estado interno
|
||||
if vlan_ifname in self.vlans:
|
||||
self.vlans.pop(vlan_ifname)
|
||||
|
||||
# Limpiar redirecciones que usen esta VLAN
|
||||
self.vlan_redirections = [
|
||||
r for r in self.vlan_redirections
|
||||
if r.get("src_iface") != vlan_ifname
|
||||
and r.get("dst_iface") != vlan_ifname
|
||||
and not (r.get("src_vlan") and f"{r.get('src_iface', '')}.{r.get('src_vlan')}" == vlan_ifname)
|
||||
and not (r.get("dst_vlan") and f"{r.get('dst_iface', '')}.{r.get('dst_vlan')}" == vlan_ifname)
|
||||
]
|
||||
|
||||
# Limpiar gateways y DHCP asociados
|
||||
if vlan_ifname in self.gateways:
|
||||
self.gateways.pop(vlan_ifname)
|
||||
if vlan_ifname in self.dhcp_servers:
|
||||
# Detener DHCP si está corriendo
|
||||
subprocess.run(["pkill", "-f", f"dnsmasq.*{vlan_ifname}"], check=False)
|
||||
self.dhcp_servers.pop(vlan_ifname)
|
||||
|
||||
return True, f"VLAN {vlan_ifname} eliminada correctamente"
|
||||
except IndexError:
|
||||
# La interfaz no existe en el sistema, pero la limpiamos del estado
|
||||
if vlan_ifname in self.vlans:
|
||||
self.vlans.pop(vlan_ifname)
|
||||
return True, f"VLAN {vlan_ifname} eliminada del estado (no existía en el sistema)"
|
||||
except Exception as e:
|
||||
return False, f"Error eliminando VLAN {vlan_ifname}: {str(e)}"
|
||||
|
||||
def redirect_vlan_tagged_to_untagged(
|
||||
self, trunk_iface: str, vlan_id: int, access_iface: str
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Redirige una VLAN tagged de un trunk a una interfaz de acceso
|
||||
creando (si es necesario) un bridge y asociando ambos puertos.
|
||||
"""
|
||||
return self.redirect_vlan_between_interfaces(
|
||||
src_iface=trunk_iface,
|
||||
src_vlan_id=vlan_id,
|
||||
dst_iface=access_iface,
|
||||
dst_vlan_id=None,
|
||||
)
|
||||
|
||||
def redirect_vlan_untagged_to_tagged(
|
||||
self, access_iface: str, vlan_id: int, trunk_iface: str
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Variante inversa: tráfico untagged de access_iface se mete en VLAN
|
||||
concreta sobre el trunk.
|
||||
"""
|
||||
# Si la VLAN ya existe, usar método directo sin intentar crear nada
|
||||
vlan_ifname = f"{trunk_iface}.{vlan_id}"
|
||||
if self.ipr.link_lookup(ifname=vlan_ifname):
|
||||
# La VLAN existe, crear bridge directo
|
||||
return self._create_bridge_between_interfaces(access_iface, vlan_ifname)
|
||||
else:
|
||||
# Intentar método genérico
|
||||
return self.redirect_vlan_between_interfaces(
|
||||
src_iface=access_iface,
|
||||
src_vlan_id=None,
|
||||
dst_iface=trunk_iface,
|
||||
dst_vlan_id=vlan_id,
|
||||
)
|
||||
|
||||
def _create_bridge_between_interfaces(
|
||||
self, iface1: str, iface2: str, bridge_name: Optional[str] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Crea un bridge simple entre dos interfaces existentes.
|
||||
No intenta crear VLANs ni nada adicional.
|
||||
"""
|
||||
try:
|
||||
if not bridge_name:
|
||||
bridge_name = f"br_{iface1.replace('.', '_')}_to_{iface2.replace('.', '_')}"
|
||||
|
||||
# Verificar que ambas interfaces existen
|
||||
idx1_list = self.ipr.link_lookup(ifname=iface1)
|
||||
idx2_list = self.ipr.link_lookup(ifname=iface2)
|
||||
|
||||
if not idx1_list:
|
||||
return False, f"Interfaz '{iface1}' no existe"
|
||||
if not idx2_list:
|
||||
return False, f"Interfaz '{iface2}' no existe"
|
||||
|
||||
idx1 = idx1_list[0]
|
||||
idx2 = idx2_list[0]
|
||||
|
||||
# Crear bridge si no existe
|
||||
br_list = self.ipr.link_lookup(ifname=bridge_name)
|
||||
if not br_list:
|
||||
self.ipr.link("add", ifname=bridge_name, kind="bridge")
|
||||
|
||||
br_idx = self.ipr.link_lookup(ifname=bridge_name)[0]
|
||||
|
||||
# Añadir interfaces al bridge
|
||||
self.ipr.link("set", index=idx1, master=br_idx)
|
||||
self.ipr.link("set", index=idx2, master=br_idx)
|
||||
|
||||
# Activar todo
|
||||
self.ipr.link("set", index=br_idx, state="up")
|
||||
self.ipr.link("set", index=idx1, state="up")
|
||||
self.ipr.link("set", index=idx2, state="up")
|
||||
|
||||
return True, f"Bridge {bridge_name} creado entre {iface1} y {iface2}"
|
||||
except Exception as e:
|
||||
return False, f"Error creando bridge: {str(e)}"
|
||||
|
||||
def redirect_vlan_between_interfaces(
|
||||
self,
|
||||
src_iface: str,
|
||||
src_vlan_id: Optional[int],
|
||||
dst_iface: str,
|
||||
dst_vlan_id: Optional[int],
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Redirección genérica entre interfaces (+/- VLAN). Implementación
|
||||
basada en bridges: se crea un bridge específico y se conectan
|
||||
las interfaces/ subinterfaces correspondientes.
|
||||
|
||||
Si la VLAN destino ya existe (ej: enp122s0.10), la usa directamente
|
||||
sin intentar crearla. Esto permite conectar interfaces que no soportan
|
||||
VLANs (como USB Ethernet) a VLANs existentes.
|
||||
"""
|
||||
if src_iface == dst_iface and src_vlan_id == dst_vlan_id:
|
||||
return False, "Source and destination cannot be identical"
|
||||
|
||||
try:
|
||||
# Determinar nombres de interfaces finales
|
||||
src_ifname = src_iface
|
||||
dst_ifname = dst_iface
|
||||
|
||||
# Origen: NUNCA intentar crear VLAN si src_vlan_id es None (untagged)
|
||||
# Si src_vlan_id está especificado, verificar si existe primero
|
||||
if src_vlan_id is not None:
|
||||
vlan_ifname = f"{src_iface}.{src_vlan_id}"
|
||||
# Verificar si ya existe
|
||||
if self.ipr.link_lookup(ifname=vlan_ifname):
|
||||
src_ifname = vlan_ifname
|
||||
else:
|
||||
# Intentar crear solo si la interfaz base soporta VLANs
|
||||
# Si falla, usamos la interfaz base directamente
|
||||
ok, msg = self.create_vlan_trunk(src_iface, [src_vlan_id])
|
||||
if ok:
|
||||
src_ifname = vlan_ifname
|
||||
# Si falla, dejamos src_ifname = src_iface (untagged)
|
||||
# No retornamos error aquí, simplemente usamos la base
|
||||
|
||||
# Destino: verificar si la VLAN ya existe ANTES de intentar crearla
|
||||
if dst_vlan_id is not None:
|
||||
vlan_ifname = f"{dst_iface}.{dst_vlan_id}"
|
||||
# Verificar si ya existe
|
||||
existing_vlan = self.ipr.link_lookup(ifname=vlan_ifname)
|
||||
if existing_vlan:
|
||||
# La VLAN ya existe, usarla directamente
|
||||
dst_ifname = vlan_ifname
|
||||
else:
|
||||
# La VLAN no existe, intentar crearla
|
||||
ok, msg = self.create_vlan_trunk(dst_iface, [dst_vlan_id])
|
||||
if ok:
|
||||
dst_ifname = vlan_ifname
|
||||
else:
|
||||
return False, f"No se pudo crear/verificar VLAN destino {vlan_ifname}: {msg}"
|
||||
|
||||
# Si el origen es untagged y el destino es una VLAN existente,
|
||||
# usar método directo de bridge simple
|
||||
if src_vlan_id is None and dst_vlan_id is not None:
|
||||
vlan_ifname = f"{dst_iface}.{dst_vlan_id}"
|
||||
if self.ipr.link_lookup(ifname=vlan_ifname):
|
||||
# La VLAN destino existe, usar método directo
|
||||
ok, msg = self._create_bridge_between_interfaces(src_iface, vlan_ifname)
|
||||
if ok:
|
||||
rule = {
|
||||
"src_iface": src_iface,
|
||||
"src_vlan": None,
|
||||
"dst_iface": dst_iface,
|
||||
"dst_vlan": dst_vlan_id,
|
||||
"bridge": msg.split(" ")[1] if "Bridge" in msg else f"br_{src_iface}_to_{vlan_ifname.replace('.', '_')}",
|
||||
}
|
||||
self.vlan_redirections.append(rule)
|
||||
return ok, msg
|
||||
|
||||
# Verificar que ambas interfaces existen antes de crear el bridge
|
||||
try:
|
||||
src_idx_list = self.ipr.link_lookup(ifname=src_ifname)
|
||||
dst_idx_list = self.ipr.link_lookup(ifname=dst_ifname)
|
||||
|
||||
if not src_idx_list:
|
||||
return False, f"Interfaz origen '{src_ifname}' no existe"
|
||||
if not dst_idx_list:
|
||||
return False, f"Interfaz destino '{dst_ifname}' no existe"
|
||||
|
||||
src_idx = src_idx_list[0]
|
||||
dst_idx = dst_idx_list[0]
|
||||
except (IndexError, Exception) as e:
|
||||
return False, f"Error verificando interfaces: origen={src_ifname}, destino={dst_ifname}, error={str(e)}"
|
||||
|
||||
# Crear bridge para esta redirección
|
||||
bridge_name = f"br_{src_ifname.replace('.', '_')}_to_{dst_ifname.replace('.', '_')}"
|
||||
|
||||
# Verificar si el bridge ya existe
|
||||
existing_bridge = self.ipr.link_lookup(ifname=bridge_name)
|
||||
if not existing_bridge:
|
||||
self.ipr.link("add", ifname=bridge_name, kind="bridge")
|
||||
|
||||
br_idx = self.ipr.link_lookup(ifname=bridge_name)[0]
|
||||
|
||||
# Añadir interfaces al bridge
|
||||
self.ipr.link("set", index=src_idx, master=br_idx)
|
||||
self.ipr.link("set", index=dst_idx, master=br_idx)
|
||||
|
||||
# Activar todo
|
||||
self.ipr.link("set", index=br_idx, state="up")
|
||||
self.ipr.link("set", index=src_idx, state="up")
|
||||
self.ipr.link("set", index=dst_idx, state="up")
|
||||
|
||||
rule = {
|
||||
"src_iface": src_iface,
|
||||
"src_vlan": src_vlan_id,
|
||||
"dst_iface": dst_iface,
|
||||
"dst_vlan": dst_vlan_id,
|
||||
"bridge": bridge_name,
|
||||
}
|
||||
self.vlan_redirections.append(rule)
|
||||
return True, f"Bridge {bridge_name} creado: {src_ifname} → {dst_ifname}"
|
||||
except Exception as e:
|
||||
error_str = str(e)
|
||||
# Mejorar mensaje de error
|
||||
if "34" in error_str or "ERANGE" in error_str or "out of range" in error_str.lower():
|
||||
return False, (
|
||||
f"Error: No se pudo crear el bridge. Posibles causas:\n"
|
||||
f"• La interfaz origen '{src_iface}' no puede ser parte de un bridge con VLANs\n"
|
||||
f"• La interfaz destino '{dst_iface}' no soporta la operación\n"
|
||||
f"• Intenta usar la pestaña 'VLAN / Puente' para crear un bridge simple"
|
||||
)
|
||||
return False, f"Error en redirección: {error_str}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bridges y VLAN filters (esqueleto, se extenderá en tareas siguientes)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_bridge_with_vlan_filters(
|
||||
self, bridge_name: str, interfaces: List[str], vlan_filters: List[Dict]
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Crea un bridge y aplica filtros VLAN en sus puertos.
|
||||
vlan_filters: lista de dicts con claves:
|
||||
{ 'iface': str, 'vid': int, 'pvid': bool, 'untagged': bool }
|
||||
"""
|
||||
try:
|
||||
if not self.ipr.link_lookup(ifname=bridge_name):
|
||||
self.ipr.link("add", ifname=bridge_name, kind="bridge")
|
||||
|
||||
br_idx = self.ipr.link_lookup(ifname=bridge_name)[0]
|
||||
|
||||
for iface in interfaces:
|
||||
idx = self.ipr.link_lookup(ifname=iface)[0]
|
||||
self.ipr.link("set", index=idx, master=br_idx)
|
||||
self.ipr.link("set", index=idx, state="up")
|
||||
|
||||
self.ipr.link("set", index=br_idx, state="up")
|
||||
|
||||
# Aplicar filtros VLAN sobre cada puerto
|
||||
for vf in vlan_filters:
|
||||
try:
|
||||
port_idx = self.ipr.link_lookup(ifname=vf["iface"])[0]
|
||||
vid = int(vf["vid"])
|
||||
flags = []
|
||||
if vf.get("pvid"):
|
||||
flags.append("pvid")
|
||||
if vf.get("untagged"):
|
||||
flags.append("untagged")
|
||||
|
||||
vlan_info = {"vid": vid}
|
||||
if flags:
|
||||
vlan_info["flags"] = flags
|
||||
|
||||
# pyroute2 expone vlan_filter desde ipr
|
||||
self.ipr.vlan_filter(
|
||||
"add",
|
||||
index=port_idx,
|
||||
vlan_info=vlan_info,
|
||||
)
|
||||
except Exception:
|
||||
# No abortamos por un fallo puntual de filtro; se puede depurar luego
|
||||
continue
|
||||
|
||||
return True, f"Bridge {bridge_name} created with VLAN filters"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def add_vlan_filter_to_bridge_port(
|
||||
self, bridge_name: str, port_iface: str, vlan_id: int, pvid: bool = False, untagged: bool = False
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
if not self.ipr.link_lookup(ifname=bridge_name):
|
||||
return False, f"Bridge {bridge_name} not found"
|
||||
|
||||
port_idx = self.ipr.link_lookup(ifname=port_iface)[0]
|
||||
flags = []
|
||||
if pvid:
|
||||
flags.append("pvid")
|
||||
if untagged:
|
||||
flags.append("untagged")
|
||||
vlan_info = {"vid": int(vlan_id)}
|
||||
if flags:
|
||||
vlan_info["flags"] = flags
|
||||
|
||||
self.ipr.vlan_filter("add", index=port_idx, vlan_info=vlan_info)
|
||||
return True, "VLAN filter added"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def remove_vlan_filter_from_bridge_port(
|
||||
self, bridge_name: str, port_iface: str, vlan_id: int
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
if not self.ipr.link_lookup(ifname=bridge_name):
|
||||
return False, f"Bridge {bridge_name} not found"
|
||||
|
||||
port_idx = self.ipr.link_lookup(ifname=port_iface)[0]
|
||||
self.ipr.vlan_filter("del", index=port_idx, vlan_info={"vid": int(vlan_id)})
|
||||
return True, "VLAN filter removed"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DHCP y Gateways
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start_dhcp_server_for_vlan(
|
||||
self,
|
||||
vlan_interface: str,
|
||||
ip: str,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
gateway: Optional[str] = None,
|
||||
dns: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Envuelve a NetworkManager.start_dhcp_server y además registra gateway/dns.
|
||||
"""
|
||||
ok, msg = self.nm.start_dhcp_server(vlan_interface, ip, range_start, range_end)
|
||||
if not ok:
|
||||
return ok, msg
|
||||
|
||||
self.dhcp_servers[vlan_interface] = {
|
||||
"ip": ip,
|
||||
"range_start": range_start,
|
||||
"range_end": range_end,
|
||||
}
|
||||
|
||||
if gateway:
|
||||
self.set_gateway_for_network(vlan_interface, ip, gateway)
|
||||
if dns:
|
||||
self.gateways.setdefault(vlan_interface, {})["dns"] = dns
|
||||
|
||||
return True, msg
|
||||
|
||||
def stop_dhcp_server_for_vlan(self, vlan_interface: str) -> Tuple[bool, str]:
|
||||
try:
|
||||
subprocess.run(["pkill", "-f", f"dnsmasq.*{vlan_interface}"], check=False)
|
||||
self.dhcp_servers.pop(vlan_interface, None)
|
||||
return True, f"DHCP server on {vlan_interface} stopped"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def list_active_dhcp_servers(self) -> Dict[str, Dict]:
|
||||
return dict(self.dhcp_servers)
|
||||
|
||||
def set_gateway_for_network(self, interface: str, ip: str, gateway: str) -> Tuple[bool, str]:
|
||||
self.gateways[interface] = {"ip": ip, "gateway": gateway}
|
||||
# A nivel de sistema, añadimos/actualizamos ruta por defecto para esa red
|
||||
try:
|
||||
self.add_route(dst="0.0.0.0/0", gateway=gateway, interface=interface)
|
||||
return True, f"Gateway {gateway} set for {interface}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def remove_gateway_for_network(self, interface: str, ip: str) -> Tuple[bool, str]:
|
||||
if interface in self.gateways:
|
||||
gw = self.gateways[interface].get("gateway")
|
||||
self.gateways.pop(interface, None)
|
||||
if gw:
|
||||
try:
|
||||
self.delete_route(dst="0.0.0.0/0", gateway=gw, interface=interface)
|
||||
except Exception:
|
||||
pass
|
||||
return True, f"Gateway removed for {interface}"
|
||||
|
||||
def list_gateways(self) -> Dict[str, Dict]:
|
||||
return dict(self.gateways)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Routing y tablas
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
dst: str,
|
||||
gateway: str,
|
||||
interface: str,
|
||||
table: int = 254,
|
||||
metric: Optional[int] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
idx = self.ipr.link_lookup(ifname=interface)[0]
|
||||
dst_ip, prefix = dst.split("/")
|
||||
attrs = [("OIF", idx), ("GATEWAY", gateway)]
|
||||
self.ipr.route(
|
||||
"add",
|
||||
dst=dst_ip,
|
||||
mask=int(prefix),
|
||||
table=table,
|
||||
metrics={"metric": metric} if metric is not None else None,
|
||||
attrs=attrs,
|
||||
)
|
||||
self.routes.append(
|
||||
{
|
||||
"dst": dst,
|
||||
"gateway": gateway,
|
||||
"interface": interface,
|
||||
"table": table,
|
||||
"metric": metric,
|
||||
}
|
||||
)
|
||||
return True, "Route added"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def delete_route(
|
||||
self,
|
||||
dst: str,
|
||||
gateway: str,
|
||||
interface: str,
|
||||
table: int = 254,
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
idx = self.ipr.link_lookup(ifname=interface)[0]
|
||||
dst_ip, prefix = dst.split("/")
|
||||
self.ipr.route(
|
||||
"del",
|
||||
dst=dst_ip,
|
||||
mask=int(prefix),
|
||||
table=table,
|
||||
attrs=[("OIF", idx), ("GATEWAY", gateway)],
|
||||
)
|
||||
self.routes = [
|
||||
r
|
||||
for r in self.routes
|
||||
if not (
|
||||
r["dst"] == dst
|
||||
and r["gateway"] == gateway
|
||||
and r["interface"] == interface
|
||||
and r["table"] == table
|
||||
)
|
||||
]
|
||||
return True, "Route deleted"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def add_routing_rule(
|
||||
self,
|
||||
table: int,
|
||||
priority: int,
|
||||
src: Optional[str] = None,
|
||||
dst: Optional[str] = None,
|
||||
iifname: Optional[str] = None,
|
||||
oifname: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
kwargs = {"table": table, "priority": priority}
|
||||
if src:
|
||||
ip, plen = src.split("/")
|
||||
kwargs["src"] = ip
|
||||
kwargs["src_len"] = int(plen)
|
||||
if dst:
|
||||
ip, plen = dst.split("/")
|
||||
kwargs["dst"] = ip
|
||||
kwargs["dst_len"] = int(plen)
|
||||
if iifname:
|
||||
kwargs["iifname"] = iifname
|
||||
if oifname:
|
||||
kwargs["oifname"] = oifname
|
||||
|
||||
self.ipr.rule("add", **kwargs)
|
||||
self.routing_rules.append(kwargs)
|
||||
return True, "Routing rule added"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def list_routes(self, table: Optional[int] = None) -> List[Dict]:
|
||||
if table is None:
|
||||
return list(self.routes)
|
||||
return [r for r in self.routes if r["table"] == table]
|
||||
|
||||
def enable_ip_forwarding(self) -> Tuple[bool, str]:
|
||||
try:
|
||||
with open("/proc/sys/net/ipv4/ip_forward", "w") as f:
|
||||
f.write("1")
|
||||
return True, "IP forwarding enabled"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NAT y Port Forwarding
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def detect_firewall_backend(self) -> str:
|
||||
"""
|
||||
Devuelve 'nftables', 'iptables' o 'none'.
|
||||
"""
|
||||
from shutil import which
|
||||
|
||||
if which("nft"):
|
||||
return "nftables"
|
||||
if which("iptables"):
|
||||
return "iptables"
|
||||
return "none"
|
||||
|
||||
def _run_iptables(self, args: List[str]) -> None:
|
||||
subprocess.run(["iptables"] + args, check=True)
|
||||
|
||||
def _run_nft(self, args: List[str]) -> None:
|
||||
subprocess.run(["nft"] + args, check=True)
|
||||
|
||||
def enable_nat_for_interface(self, interface: str, external_iface: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Habilita SNAT/MASQUERADE para que las redes internas salgan por external_iface.
|
||||
Implementación mínima usando iptables o nftables.
|
||||
"""
|
||||
try:
|
||||
if self.firewall_backend == "iptables":
|
||||
# Habilitar forwarding
|
||||
self._run_iptables(["-A", "FORWARD", "-i", interface, "-o", external_iface, "-j", "ACCEPT"])
|
||||
self._run_iptables(["-A", "FORWARD", "-i", external_iface, "-o", interface, "-m", "state", "--state", "ESTABLISHED,RELATED", "-j", "ACCEPT"])
|
||||
# NAT
|
||||
self._run_iptables(["-t", "nat", "-A", "POSTROUTING", "-o", external_iface, "-j", "MASQUERADE"])
|
||||
elif self.firewall_backend == "nftables":
|
||||
# Asumimos tabla inet nat preexistente o intentamos crear algo simple
|
||||
self._run_nft(["add", "table", "inet", "jnc_nat"])
|
||||
self._run_nft(["add", "chain", "inet", "jnc_nat", "postrouting", "{", "type", "nat", "hook", "postrouting", "priority", "100", ";", "}"])
|
||||
self._run_nft(
|
||||
[
|
||||
"add",
|
||||
"rule",
|
||||
"inet",
|
||||
"jnc_nat",
|
||||
"postrouting",
|
||||
"oifname",
|
||||
external_iface,
|
||||
"masquerade",
|
||||
]
|
||||
)
|
||||
else:
|
||||
return False, "No firewall backend (iptables/nftables) available"
|
||||
|
||||
self.nat_rules.append({"interface": interface, "external": external_iface})
|
||||
return True, "NAT enabled"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def disable_nat_for_interface(self, interface: str) -> Tuple[bool, str]:
|
||||
# Implementación sencilla: no sabemos exactamente qué reglas borrar,
|
||||
# así que por ahora dejamos registro lógico y se puede extender.
|
||||
self.nat_rules = [r for r in self.nat_rules if r["interface"] != interface]
|
||||
return True, "NAT rules removed from internal state (system rules may persist)"
|
||||
|
||||
def add_port_forwarding(
|
||||
self,
|
||||
external_ip: str,
|
||||
external_port: int,
|
||||
internal_ip: str,
|
||||
internal_port: int,
|
||||
protocol: str = "tcp",
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
if self.firewall_backend == "iptables":
|
||||
self._run_iptables(
|
||||
[
|
||||
"-t",
|
||||
"nat",
|
||||
"-A",
|
||||
"PREROUTING",
|
||||
"-p",
|
||||
protocol,
|
||||
"-d",
|
||||
external_ip,
|
||||
"--dport",
|
||||
str(external_port),
|
||||
"-j",
|
||||
"DNAT",
|
||||
"--to-destination",
|
||||
f"{internal_ip}:{internal_port}",
|
||||
]
|
||||
)
|
||||
elif self.firewall_backend == "nftables":
|
||||
self._run_nft(["add", "table", "inet", "jnc_nat"])
|
||||
self._run_nft(["add", "chain", "inet", "jnc_nat", "prerouting", "{", "type", "nat", "hook", "prerouting", "priority", "0", ";", "}"])
|
||||
self._run_nft(
|
||||
[
|
||||
"add",
|
||||
"rule",
|
||||
"inet",
|
||||
"jnc_nat",
|
||||
"prerouting",
|
||||
"ip",
|
||||
"daddr",
|
||||
external_ip,
|
||||
"tcp",
|
||||
"dport",
|
||||
str(external_port),
|
||||
"dnat",
|
||||
"to",
|
||||
f"{internal_ip}:{internal_port}",
|
||||
]
|
||||
)
|
||||
else:
|
||||
return False, "No firewall backend (iptables/nftables) available"
|
||||
|
||||
rule = {
|
||||
"external_ip": external_ip,
|
||||
"external_port": external_port,
|
||||
"internal_ip": internal_ip,
|
||||
"internal_port": internal_port,
|
||||
"protocol": protocol,
|
||||
}
|
||||
self.nat_rules.append(rule)
|
||||
return True, "Port forwarding rule added"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Firewall / Visibilidad entre VLANs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_firewall_rule(
|
||||
self,
|
||||
chain: str,
|
||||
action: str,
|
||||
src_network: Optional[str] = None,
|
||||
dst_network: Optional[str] = None,
|
||||
protocol: Optional[str] = None,
|
||||
src_port: Optional[int] = None,
|
||||
dst_port: Optional[int] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Regla genérica simple sobre FORWARD/INPUT/OUTPUT.
|
||||
"""
|
||||
try:
|
||||
if self.firewall_backend == "iptables":
|
||||
args = ["-A", chain]
|
||||
if src_network:
|
||||
args += ["-s", src_network]
|
||||
if dst_network:
|
||||
args += ["-d", dst_network]
|
||||
if protocol:
|
||||
args += ["-p", protocol]
|
||||
if src_port:
|
||||
args += ["--sport", str(src_port)]
|
||||
if dst_port:
|
||||
args += ["--dport", str(dst_port)]
|
||||
args += ["-j", action]
|
||||
self._run_iptables(args)
|
||||
elif self.firewall_backend == "nftables":
|
||||
# Implementación básica centrada en cadena forward de tabla inet filter
|
||||
self._run_nft(["add", "table", "inet", "jnc_filter"])
|
||||
self._run_nft(["add", "chain", "inet", "jnc_filter", "forward", "{", "type", "filter", "hook", "forward", "priority", "0", ";", "}"])
|
||||
rule_parts = ["add", "rule", "inet", "jnc_filter", "forward"]
|
||||
if src_network:
|
||||
rule_parts += ["ip", "saddr", src_network]
|
||||
if dst_network:
|
||||
rule_parts += ["ip", "daddr", dst_network]
|
||||
if protocol:
|
||||
rule_parts.append(protocol)
|
||||
if dst_port:
|
||||
rule_parts += ["dport", str(dst_port)]
|
||||
rule_parts.append(action.lower())
|
||||
self._run_nft(rule_parts)
|
||||
else:
|
||||
return False, "No firewall backend (iptables/nftables) available"
|
||||
|
||||
rule = {
|
||||
"chain": chain,
|
||||
"action": action,
|
||||
"src": src_network,
|
||||
"dst": dst_network,
|
||||
"protocol": protocol,
|
||||
"src_port": src_port,
|
||||
"dst_port": dst_port,
|
||||
}
|
||||
self.firewall_rules.append(rule)
|
||||
return True, "Firewall rule added"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def block_vlan_to_vlan(self, src_vlan_id: int, dst_vlan_id: int) -> Tuple[bool, str]:
|
||||
src_net = f"10.{src_vlan_id}.0.0/16"
|
||||
dst_net = f"10.{dst_vlan_id}.0.0/16"
|
||||
ok, msg = self.add_firewall_rule(
|
||||
chain="FORWARD",
|
||||
action="DROP",
|
||||
src_network=src_net,
|
||||
dst_network=dst_net,
|
||||
)
|
||||
if ok:
|
||||
self.vlan_visibility[(src_vlan_id, dst_vlan_id)] = False
|
||||
return ok, msg
|
||||
|
||||
def allow_vlan_to_vlan(self, src_vlan_id: int, dst_vlan_id: int) -> Tuple[bool, str]:
|
||||
src_net = f"10.{src_vlan_id}.0.0/16"
|
||||
dst_net = f"10.{dst_vlan_id}.0.0/16"
|
||||
ok, msg = self.add_firewall_rule(
|
||||
chain="FORWARD",
|
||||
action="ACCEPT",
|
||||
src_network=src_net,
|
||||
dst_network=dst_net,
|
||||
)
|
||||
if ok:
|
||||
self.vlan_visibility[(src_vlan_id, dst_vlan_id)] = True
|
||||
return ok, msg
|
||||
|
||||
def set_vlan_isolation_mode(self, vlan_id: int, isolated: bool = True) -> Tuple[bool, str]:
|
||||
"""
|
||||
Implementación mínima: registra el estado lógico. Las reglas reales
|
||||
se pueden derivar en la UI según las necesidades del despliegue.
|
||||
"""
|
||||
if isolated:
|
||||
# Bloqueamos VLAN contra todas las demás de forma lógica
|
||||
for other in range(1, 4095):
|
||||
if other == vlan_id:
|
||||
continue
|
||||
self.vlan_visibility[(vlan_id, other)] = False
|
||||
self.vlan_visibility[(other, vlan_id)] = False
|
||||
return True, "VLAN marked as isolated (logical state)"
|
||||
else:
|
||||
# Permitimos de nuevo (sólo borra del estado interno)
|
||||
self.vlan_visibility = {
|
||||
k: v
|
||||
for k, v in self.vlan_visibility.items()
|
||||
if vlan_id not in k
|
||||
}
|
||||
return True, "VLAN isolation disabled in logical state"
|
||||
|
||||
def list_firewall_rules(self) -> List[Dict]:
|
||||
return list(self.firewall_rules)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gestión de estado
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_routing_status(self) -> Dict:
|
||||
return {
|
||||
"vlans": self.vlans,
|
||||
"vlan_redirections": self.vlan_redirections,
|
||||
"dhcp_servers": self.dhcp_servers,
|
||||
"gateways": self.gateways,
|
||||
"routes": self.routes,
|
||||
"routing_rules": self.routing_rules,
|
||||
"nat_rules": self.nat_rules,
|
||||
"firewall_rules": self.firewall_rules,
|
||||
"vlan_visibility": {f"{a}-{b}": allowed for (a, b), allowed in self.vlan_visibility.items()},
|
||||
}
|
||||
|
||||
def save_routing_config(self, filename: str) -> Tuple[bool, str]:
|
||||
try:
|
||||
data = self.get_routing_status()
|
||||
with open(filename, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
return True, filename
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def load_routing_config(self, filename: str) -> Tuple[bool, str]:
|
||||
try:
|
||||
with open(filename, "r") as f:
|
||||
data = json.load(f)
|
||||
# Sólo restauramos estado en memoria; aplicar en sistema sería más complejo
|
||||
self.vlans = data.get("vlans", {})
|
||||
self.vlan_redirections = data.get("vlan_redirections", [])
|
||||
self.dhcp_servers = data.get("dhcp_servers", {})
|
||||
self.gateways = data.get("gateways", {})
|
||||
self.routes = data.get("routes", [])
|
||||
self.routing_rules = data.get("routing_rules", [])
|
||||
self.nat_rules = data.get("nat_rules", [])
|
||||
self.firewall_rules = data.get("firewall_rules", [])
|
||||
vis = data.get("vlan_visibility", {})
|
||||
self.vlan_visibility = {}
|
||||
for key, allowed in vis.items():
|
||||
try:
|
||||
a_str, b_str = key.split("-")
|
||||
self.vlan_visibility[(int(a_str), int(b_str))] = bool(allowed)
|
||||
except Exception:
|
||||
continue
|
||||
return True, "Routing configuration loaded into memory (no live apply)"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
@@ -4,6 +4,8 @@ import struct
|
||||
import psutil
|
||||
import subprocess
|
||||
import json
|
||||
import time
|
||||
from typing import Tuple
|
||||
from pyroute2 import IPRoute
|
||||
|
||||
class NetworkManager:
|
||||
@@ -370,5 +372,30 @@ rsn_pairwise=CCMP
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def reset_interface(self, iface_name: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Reinicia una interfaz de red haciendo down y luego up.
|
||||
Útil cuando una interfaz se queda "tonta" y necesita reiniciarse.
|
||||
"""
|
||||
try:
|
||||
iface_idx = self.ipr.link_lookup(ifname=iface_name)
|
||||
if not iface_idx:
|
||||
return False, f"Interfaz {iface_name} no encontrada"
|
||||
|
||||
idx = iface_idx[0]
|
||||
|
||||
# Bajar la interfaz
|
||||
self.ipr.link("set", index=idx, state="down")
|
||||
|
||||
# Pequeña pausa para asegurar que el down se procesa
|
||||
time.sleep(0.5)
|
||||
|
||||
# Subir la interfaz
|
||||
self.ipr.link("set", index=idx, state="up")
|
||||
|
||||
return True, f"Interfaz {iface_name} reiniciada correctamente (down → up)"
|
||||
except Exception as e:
|
||||
return False, f"Error al reiniciar interfaz {iface_name}: {str(e)}"
|
||||
|
||||
def close(self):
|
||||
self.ipr.close()
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
|
||||
class ConnectivityTestThread(QThread):
|
||||
"""Thread para ejecutar tests de conectividad en paralelo."""
|
||||
test_result = pyqtSignal(dict) # Emite resultado de cada test
|
||||
all_finished = pyqtSignal() # Emite cuando todos terminan
|
||||
|
||||
def __init__(self, tests: List[Dict]):
|
||||
super().__init__()
|
||||
self.tests = tests
|
||||
self.results = []
|
||||
|
||||
def run(self):
|
||||
for test in self.tests:
|
||||
result = self._run_test(test)
|
||||
self.results.append(result)
|
||||
self.test_result.emit(result)
|
||||
self.all_finished.emit()
|
||||
|
||||
def _run_test(self, test: Dict) -> Dict:
|
||||
"""Ejecuta un test individual y retorna resultado."""
|
||||
host = test["host"]
|
||||
port = test["port"]
|
||||
protocol = test.get("protocol", "TCP")
|
||||
test_type = test.get("type", "connectivity")
|
||||
|
||||
result = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"protocol": protocol,
|
||||
"type": test_type,
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"success": False,
|
||||
"status": "Unknown",
|
||||
"latency_ms": None,
|
||||
"error": None,
|
||||
"details": "",
|
||||
}
|
||||
|
||||
try:
|
||||
if protocol == "TCP":
|
||||
result.update(self._test_tcp(host, port))
|
||||
elif protocol == "UDP":
|
||||
if test_type == "ntp":
|
||||
result.update(self._test_ntp(host))
|
||||
else:
|
||||
result.update(self._test_udp(host, port))
|
||||
else:
|
||||
result["error"] = f"Protocolo desconocido: {protocol}"
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
result["status"] = "Error"
|
||||
|
||||
return result
|
||||
|
||||
def _test_tcp(self, host: str, port: int) -> Dict:
|
||||
"""Test de conectividad TCP."""
|
||||
start_time = time.time()
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
result = sock.connect_ex((host, port))
|
||||
latency = (time.time() - start_time) * 1000
|
||||
sock.close()
|
||||
|
||||
if result == 0:
|
||||
return {
|
||||
"success": True,
|
||||
"status": "Open",
|
||||
"latency_ms": round(latency, 2),
|
||||
"details": f"Conexión TCP exitosa en {latency:.2f}ms",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"status": "Closed/Filtered",
|
||||
"latency_ms": None,
|
||||
"details": f"No se pudo conectar (código: {result})",
|
||||
}
|
||||
except socket.timeout:
|
||||
return {
|
||||
"success": False,
|
||||
"status": "Timeout",
|
||||
"latency_ms": None,
|
||||
"details": "Timeout esperando respuesta",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"status": "Error",
|
||||
"latency_ms": None,
|
||||
"error": str(e),
|
||||
"details": f"Error: {str(e)}",
|
||||
}
|
||||
|
||||
def _test_udp(self, host: str, port: int) -> Dict:
|
||||
"""Test básico UDP (envía paquete y espera respuesta)."""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(3)
|
||||
start_time = time.time()
|
||||
|
||||
# Enviar paquete vacío
|
||||
sock.sendto(b"", (host, port))
|
||||
|
||||
try:
|
||||
data, addr = sock.recvfrom(1024)
|
||||
latency = (time.time() - start_time) * 1000
|
||||
sock.close()
|
||||
return {
|
||||
"success": True,
|
||||
"status": "Open",
|
||||
"latency_ms": round(latency, 2),
|
||||
"details": f"Respuesta UDP recibida en {latency:.2f}ms",
|
||||
}
|
||||
except socket.timeout:
|
||||
sock.close()
|
||||
return {
|
||||
"success": False,
|
||||
"status": "Open|Filtered",
|
||||
"latency_ms": None,
|
||||
"details": "No respuesta (puerto puede estar abierto pero filtrado)",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"status": "Error",
|
||||
"latency_ms": None,
|
||||
"error": str(e),
|
||||
"details": f"Error: {str(e)}",
|
||||
}
|
||||
|
||||
def _test_ntp(self, host: str) -> Dict:
|
||||
"""Test NTP específico (puerto UDP 123)."""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(5)
|
||||
start_time = time.time()
|
||||
|
||||
# Paquete NTP básico (modo 3: cliente)
|
||||
ntp_packet = bytearray(48)
|
||||
ntp_packet[0] = 0x1B # LI=0, VN=3, Mode=3
|
||||
|
||||
sock.sendto(ntp_packet, (host, 123))
|
||||
|
||||
try:
|
||||
data, addr = sock.recvfrom(1024)
|
||||
latency = (time.time() - start_time) * 1000
|
||||
|
||||
if len(data) >= 48:
|
||||
# Extraer timestamp NTP
|
||||
try:
|
||||
seconds = (
|
||||
(data[40] << 24)
|
||||
| (data[41] << 16)
|
||||
| (data[42] << 8)
|
||||
| data[43]
|
||||
)
|
||||
# NTP epoch: 1900-01-01
|
||||
ntp_epoch = datetime(1900, 1, 1)
|
||||
ntp_time = ntp_epoch.timestamp() + seconds
|
||||
ntp_dt = datetime.fromtimestamp(ntp_time)
|
||||
time_str = ntp_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, OverflowError, OSError):
|
||||
time_str = "Timestamp no válido"
|
||||
|
||||
sock.close()
|
||||
return {
|
||||
"success": True,
|
||||
"status": "Open",
|
||||
"latency_ms": round(latency, 2),
|
||||
"details": f"NTP hora local: {time_str}",
|
||||
}
|
||||
else:
|
||||
sock.close()
|
||||
return {
|
||||
"success": False,
|
||||
"status": "Error",
|
||||
"latency_ms": None,
|
||||
"details": "Respuesta NTP incompleta",
|
||||
}
|
||||
except socket.timeout:
|
||||
sock.close()
|
||||
return {
|
||||
"success": False,
|
||||
"status": "Timeout",
|
||||
"latency_ms": None,
|
||||
"details": "Timeout esperando respuesta NTP",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"status": "Error",
|
||||
"latency_ms": None,
|
||||
"error": str(e),
|
||||
"details": f"Error NTP: {str(e)}",
|
||||
}
|
||||
|
||||
|
||||
class VLAN35Checker:
|
||||
"""Gestor de tests de conectividad para VLAN 35."""
|
||||
|
||||
def __init__(self):
|
||||
# Configuración por defecto basada en el script .bat
|
||||
self.default_tests = [
|
||||
{"host": "10.47.8.133", "port": 515, "protocol": "TCP", "type": "connectivity"},
|
||||
{"host": "10.47.8.133", "port": 9195, "protocol": "TCP", "type": "connectivity"},
|
||||
{"host": "10.47.8.135", "port": 9191, "protocol": "TCP", "type": "connectivity"},
|
||||
{"host": "10.47.8.135", "port": 9192, "protocol": "TCP", "type": "connectivity"},
|
||||
{"host": "10.240.225.171", "port": 465, "protocol": "TCP", "type": "connectivity"},
|
||||
{"host": "10.244.255.250", "port": 123, "protocol": "UDP", "type": "ntp"},
|
||||
]
|
||||
|
||||
def get_default_tests(self) -> List[Dict]:
|
||||
"""Retorna la lista de tests por defecto."""
|
||||
return self.default_tests.copy()
|
||||
|
||||
def run_tests(self, tests: List[Dict]) -> ConnectivityTestThread:
|
||||
"""Inicia los tests en un thread."""
|
||||
thread = ConnectivityTestThread(tests)
|
||||
thread.start()
|
||||
return thread
|
||||
Binary file not shown.
Binary file not shown.
+89
-27
@@ -1,5 +1,13 @@
|
||||
from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QTabWidget,
|
||||
QLabel, QHBoxLayout, QStatusBar)
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow,
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QTabWidget,
|
||||
QLabel,
|
||||
QHBoxLayout,
|
||||
QStatusBar,
|
||||
QFrame,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from src.core.network_manager import NetworkManager
|
||||
from src.gui.widgets.interface_list import InterfaceListWidget
|
||||
@@ -9,65 +17,118 @@ from src.gui.widgets.scanner_view import ScannerWidget
|
||||
from src.gui.widgets.sniffer_view import SnifferWidget
|
||||
from src.gui.widgets.diagnostics_view import DiagnosticsWidget
|
||||
from src.gui.widgets.port_scanner import PortScannerWidget
|
||||
from src.gui.widgets.advanced_routing import AdvancedRoutingWidget
|
||||
from src.gui.widgets.network_monitor import NetworkMonitorWidget
|
||||
from src.gui.widgets.vlan35_check import VLAN35CheckWidget
|
||||
from src.utils.report_generator import ReportGenerator
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("JNC-NetTools // ADA version")
|
||||
self.setMinimumSize(1000, 700) # Allow resizing, set minimum size
|
||||
self.setWindowTitle("JNC-NetTools · ADA - Agencia Digital de Andalucía")
|
||||
self.setMinimumSize(1100, 720) # Tamaño mínimo algo más amplio
|
||||
|
||||
self.nm = NetworkManager()
|
||||
|
||||
# Central Widget
|
||||
# Central Widget y layout principal vertical
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
main_layout = QHBoxLayout(central_widget)
|
||||
|
||||
# Sidebar (Interface List)
|
||||
outer_layout = QVBoxLayout(central_widget)
|
||||
outer_layout.setContentsMargins(14, 10, 14, 10)
|
||||
outer_layout.setSpacing(10)
|
||||
|
||||
# Encabezado corporativo superior
|
||||
header = QFrame()
|
||||
header.setObjectName("HeaderBar")
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(12, 8, 12, 8)
|
||||
|
||||
title_block = QVBoxLayout()
|
||||
title_label = QLabel("JNC-NetTools")
|
||||
title_label.setObjectName("HeaderTitle")
|
||||
subtitle_label = QLabel("Suite de diagnóstico y exploración de red · ADA")
|
||||
subtitle_label.setObjectName("HeaderSubtitle")
|
||||
subtitle_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
title_block.addWidget(title_label)
|
||||
title_block.addWidget(subtitle_label)
|
||||
|
||||
header_layout.addLayout(title_block)
|
||||
header_layout.addStretch()
|
||||
|
||||
org_label = QLabel("Agencia Digital de Andalucía · Junta de Andalucía")
|
||||
org_label.setObjectName("HeaderOrg")
|
||||
header_layout.addWidget(org_label)
|
||||
|
||||
outer_layout.addWidget(header)
|
||||
|
||||
# Separador visual
|
||||
separator = QFrame()
|
||||
separator.setFrameShape(QFrame.Shape.HLine)
|
||||
separator.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
outer_layout.addWidget(separator)
|
||||
|
||||
# Área de contenido principal (sidebar + pestañas)
|
||||
main_layout = QHBoxLayout()
|
||||
main_layout.setSpacing(12)
|
||||
outer_layout.addLayout(main_layout)
|
||||
|
||||
# Sidebar (lista de interfaces)
|
||||
self.interface_list = InterfaceListWidget(self.nm)
|
||||
self.interface_list.setFixedWidth(300)
|
||||
self.interface_list.interface_selected.connect(self.on_interface_selected)
|
||||
main_layout.addWidget(self.interface_list)
|
||||
|
||||
# Main Content Area (Tabs)
|
||||
# Área principal de pestañas
|
||||
self.tabs = QTabWidget()
|
||||
main_layout.addWidget(self.tabs)
|
||||
|
||||
# IP Configuration Tab
|
||||
# Pestaña Configuración IP
|
||||
self.ip_config_widget = IPConfigWidget(self.nm)
|
||||
self.tabs.addTab(self.ip_config_widget, "IP Configuration")
|
||||
self.tabs.addTab(self.ip_config_widget, "Configuración IP")
|
||||
|
||||
# VLAN & Bridge Tab
|
||||
# Pestaña VLAN & Bridge
|
||||
self.vlan_bridge_widget = VLANBridgeWidget(self.nm)
|
||||
self.tabs.addTab(self.vlan_bridge_widget, "VLAN / Bridge")
|
||||
self.tabs.addTab(self.vlan_bridge_widget, "VLAN / Puente")
|
||||
|
||||
# Scanner Tab (Network Discovery)
|
||||
# Pestaña de escaneo de red
|
||||
self.scanner_widget = ScannerWidget()
|
||||
self.tabs.addTab(self.scanner_widget, "Net Scanner")
|
||||
self.tabs.addTab(self.scanner_widget, "Exploración de Red")
|
||||
|
||||
# Port Scanner Tab (Dedicated)
|
||||
# Pestaña de escáner de puertos
|
||||
self.port_scanner_widget = PortScannerWidget()
|
||||
self.tabs.addTab(self.port_scanner_widget, "Port Scanner")
|
||||
self.tabs.addTab(self.port_scanner_widget, "Escáner de Puertos")
|
||||
|
||||
# Sniffer Tab
|
||||
# Pestaña Sniffer
|
||||
self.sniffer_widget = SnifferWidget(self.nm)
|
||||
self.tabs.addTab(self.sniffer_widget, "Sniffer")
|
||||
self.tabs.addTab(self.sniffer_widget, "Sniffer de Tráfico")
|
||||
|
||||
# Diagnostics Tab
|
||||
# Pestaña Diagnóstico
|
||||
self.diagnostics_widget = DiagnosticsWidget()
|
||||
self.tabs.addTab(self.diagnostics_widget, "Diagnostics")
|
||||
self.tabs.addTab(self.diagnostics_widget, "Diagnóstico")
|
||||
|
||||
# Pestaña Monitor de Red
|
||||
self.network_monitor_widget = NetworkMonitorWidget(self.nm)
|
||||
self.tabs.addTab(self.network_monitor_widget, "Monitor de Red")
|
||||
|
||||
# Pestaña Routing Avanzado
|
||||
self.advanced_routing_widget = AdvancedRoutingWidget(self.nm)
|
||||
self.tabs.addTab(self.advanced_routing_widget, "Routing Avanzado")
|
||||
|
||||
# Pestaña Comprobación VLAN35
|
||||
self.vlan35_check_widget = VLAN35CheckWidget()
|
||||
self.tabs.addTab(self.vlan35_check_widget, "Comprobación VLAN35")
|
||||
|
||||
# Report Button (Top Right or Bottom)
|
||||
# Let's add a toolbar for global actions
|
||||
toolbar = self.addToolBar("Main")
|
||||
toolbar.addAction("Generate Report", self.generate_report)
|
||||
# Barra de herramientas para acciones globales
|
||||
toolbar = self.addToolBar("Principal")
|
||||
toolbar.setMovable(False)
|
||||
toolbar.setFloatable(False)
|
||||
toolbar.addAction("Generar informe HTML", self.generate_report)
|
||||
|
||||
# Status Bar
|
||||
# Barra de estado
|
||||
self.status_bar = QStatusBar()
|
||||
self.setStatusBar(self.status_bar)
|
||||
self.status_bar.showMessage("Ready")
|
||||
self.status_bar.showMessage("Listo")
|
||||
|
||||
def on_interface_selected(self, iface_data):
|
||||
self.status_bar.showMessage(f"Selected Interface: {iface_data['name']}")
|
||||
@@ -78,6 +139,7 @@ class MainWindow(QMainWindow):
|
||||
self.scanner_widget.close()
|
||||
self.port_scanner_widget.close()
|
||||
self.vlan_bridge_widget.close()
|
||||
# advanced_routing_widget no mantiene hilos propios, sólo estado lógico
|
||||
event.accept()
|
||||
|
||||
def generate_report(self):
|
||||
|
||||
+87
-62
@@ -1,20 +1,25 @@
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtGui import QPalette, QColor
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
def apply_styles(app: QApplication):
|
||||
"""Applies a premium dark theme to the application."""
|
||||
"""
|
||||
Aplica un tema oscuro corporativo inspirado en la identidad visual
|
||||
de la Agencia Digital de Andalucía / Junta de Andalucía.
|
||||
"""
|
||||
app.setStyle("Fusion")
|
||||
|
||||
|
||||
palette = QPalette()
|
||||
|
||||
# Colors
|
||||
dark_bg = QColor(30, 30, 30)
|
||||
darker_bg = QColor(20, 20, 20)
|
||||
text_color = QColor(230, 230, 230)
|
||||
accent_color = QColor(0, 120, 215) # A nice blue
|
||||
accent_hover = QColor(0, 140, 240)
|
||||
disabled_text = QColor(127, 127, 127)
|
||||
|
||||
|
||||
# Colores corporativos aproximados ADA / Junta de Andalucía
|
||||
dark_bg = QColor(18, 18, 18)
|
||||
darker_bg = QColor(10, 10, 10)
|
||||
text_color = QColor(235, 235, 235)
|
||||
accent_color = QColor(0, 133, 66) # Verde principal corporativo
|
||||
accent_hover = QColor(0, 153, 80)
|
||||
disabled_text = QColor(130, 130, 130)
|
||||
|
||||
palette.setColor(QPalette.ColorRole.Window, dark_bg)
|
||||
palette.setColor(QPalette.ColorRole.WindowText, text_color)
|
||||
palette.setColor(QPalette.ColorRole.Base, darker_bg)
|
||||
@@ -28,29 +33,28 @@ def apply_styles(app: QApplication):
|
||||
palette.setColor(QPalette.ColorRole.Link, accent_color)
|
||||
palette.setColor(QPalette.ColorRole.Highlight, accent_color)
|
||||
palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black)
|
||||
|
||||
|
||||
app.setPalette(palette)
|
||||
|
||||
# Stylesheet for specific widgets to add that "premium" feel
|
||||
# Stylesheet for specific widgets to add that "premium" feel
|
||||
|
||||
# Stylesheet para dar un aspecto más “suite corporativa”
|
||||
app.setStyleSheet("""
|
||||
QMainWindow {
|
||||
background-color: #181818;
|
||||
background-color: #101010;
|
||||
}
|
||||
QWidget {
|
||||
font-family: 'Segoe UI', 'Roboto', sans-serif;
|
||||
font-family: 'Segoe UI', 'Roboto', 'Noto Sans', sans-serif;
|
||||
font-size: 13px;
|
||||
color: #e0e0e0;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #333333;
|
||||
background: #252526;
|
||||
border: 1px solid #2b2b2b;
|
||||
background: #181818;
|
||||
border-radius: 4px;
|
||||
top: -1px;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background: #1e1e1e;
|
||||
color: #999999;
|
||||
background: #151515;
|
||||
color: #aaaaaa;
|
||||
padding: 10px 20px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
@@ -59,17 +63,17 @@ def apply_styles(app: QApplication):
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background: #252526;
|
||||
background: #181818;
|
||||
color: #ffffff;
|
||||
border-top: 2px solid #007acc;
|
||||
border-bottom: 1px solid #252526;
|
||||
border-top: 2px solid #008542;
|
||||
border-bottom: 1px solid #181818;
|
||||
}
|
||||
QTabBar::tab:hover {
|
||||
background: #2d2d2d;
|
||||
background: #202020;
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
background-color: #007acc;
|
||||
background-color: #008542;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
@@ -77,89 +81,110 @@ def apply_styles(app: QApplication):
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #0062a3;
|
||||
background-color: #009b4f;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #004c80;
|
||||
background-color: #006f39;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #333333;
|
||||
background-color: #2a2a2a;
|
||||
color: #666666;
|
||||
}
|
||||
QLineEdit, QComboBox, QSpinBox {
|
||||
background-color: #333333;
|
||||
border: 1px solid #444444;
|
||||
color: #f0f0f0;
|
||||
padding: 6px;
|
||||
QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {
|
||||
background-color: #232323;
|
||||
border: 1px solid #3a3a3a;
|
||||
color: #f5f5f5;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
min-height: 22px;
|
||||
}
|
||||
QLineEdit:focus, QComboBox:focus, QSpinBox:focus {
|
||||
border: 1px solid #007acc;
|
||||
background-color: #3c3c3c;
|
||||
QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {
|
||||
border: 1px solid #008542;
|
||||
background-color: #272727;
|
||||
}
|
||||
/* Botones de los spinbox: que se vean siempre claros */
|
||||
QSpinBox::up-button, QDoubleSpinBox::up-button,
|
||||
QSpinBox::down-button, QDoubleSpinBox::down-button {
|
||||
background-color: #303030;
|
||||
border: 1px solid #3a3a3a;
|
||||
width: 16px;
|
||||
}
|
||||
QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover,
|
||||
QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
QSpinBox::up-arrow, QDoubleSpinBox::up-arrow,
|
||||
QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
/* Formularios más compactos */
|
||||
QFormLayout QLabel {
|
||||
min-width: 110px;
|
||||
}
|
||||
QGroupBox {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
QTableWidget {
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #333333;
|
||||
background-color: #141414;
|
||||
border: 1px solid #2b2b2b;
|
||||
border-radius: 4px;
|
||||
gridline-color: #333333;
|
||||
selection-background-color: #264f78;
|
||||
selection-background-color: #184b33;
|
||||
selection-color: white;
|
||||
alternate-background-color: #252526;
|
||||
alternate-background-color: #181818;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background-color: #252526;
|
||||
color: #cccccc;
|
||||
background-color: #181818;
|
||||
color: #dddddd;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-right: 1px solid #333333;
|
||||
border-bottom: 1px solid #333333;
|
||||
border-right: 1px solid #2b2b2b;
|
||||
border-bottom: 1px solid #2b2b2b;
|
||||
font-weight: 600;
|
||||
}
|
||||
QListWidget {
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #333333;
|
||||
background-color: #141414;
|
||||
border: 1px solid #2b2b2b;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QListWidget::item {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #252526;
|
||||
border-bottom: 1px solid #202020;
|
||||
}
|
||||
QListWidget::item:selected {
|
||||
background-color: #37373d;
|
||||
background-color: #184b33;
|
||||
color: white;
|
||||
border-left: 2px solid #007acc;
|
||||
border-left: 2px solid #00a357;
|
||||
}
|
||||
QGroupBox {
|
||||
border: 1px solid #444444;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
padding: 0 5px;
|
||||
color: #007acc;
|
||||
color: #00a357;
|
||||
left: 10px;
|
||||
}
|
||||
QStatusBar {
|
||||
background-color: #007acc;
|
||||
background-color: #101010;
|
||||
color: white;
|
||||
}
|
||||
QProgressBar {
|
||||
border: 1px solid #444444;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
background-color: #333333;
|
||||
background-color: #232323;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #007acc;
|
||||
background-color: #00a357;
|
||||
width: 10px;
|
||||
}
|
||||
QSplitter::handle {
|
||||
background-color: #333333;
|
||||
background-color: #2b2b2b;
|
||||
}
|
||||
""")
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,765 @@
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QTabWidget,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QHeaderView,
|
||||
QGroupBox,
|
||||
QFormLayout,
|
||||
QComboBox,
|
||||
QCheckBox,
|
||||
QSpinBox,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from src.core.advanced_router import AdvancedRouter
|
||||
from src.core.network_manager import NetworkManager
|
||||
|
||||
|
||||
class AdvancedRoutingWidget(QWidget):
|
||||
"""
|
||||
Pestaña principal de \"Routing Avanzado\".
|
||||
Organiza la funcionalidad en subpestañas internas:
|
||||
- VLANs
|
||||
- Redirección
|
||||
- Redes (gateway/DHCP)
|
||||
- Routing
|
||||
- NAT
|
||||
- Firewall
|
||||
"""
|
||||
|
||||
def __init__(self, network_manager: NetworkManager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.nm = network_manager
|
||||
self.router = AdvancedRouter(self.nm)
|
||||
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
title = QLabel("Routing Avanzado")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
title.setStyleSheet("font-size: 18px; font-weight: 600;")
|
||||
main_layout.addWidget(title)
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
main_layout.addWidget(self.tabs)
|
||||
|
||||
# Subpestañas
|
||||
self._init_vlans_tab()
|
||||
self._init_redirection_tab()
|
||||
self._init_networks_tab()
|
||||
self._init_routing_tab()
|
||||
self._init_nat_tab()
|
||||
self._init_firewall_tab()
|
||||
|
||||
self.refresh_interfaces()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VLANs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_vlans_tab(self):
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
# Formulario creación VLANs
|
||||
form_group = QGroupBox("Gestión de VLANs")
|
||||
form_layout = QFormLayout(form_group)
|
||||
|
||||
self.vlan_mode_combo = QComboBox()
|
||||
self.vlan_mode_combo.addItems(
|
||||
["Trunk (Tagged)", "Access (Untagged)"]
|
||||
)
|
||||
|
||||
self.vlan_iface_combo = QComboBox()
|
||||
self.vlan_ids_input = QLineEdit()
|
||||
self.vlan_ids_input.setPlaceholderText("Ej: 10,20,30 o 100-120")
|
||||
|
||||
create_btn = QPushButton("Crear VLAN(es)")
|
||||
create_btn.clicked.connect(self.on_create_vlans_clicked)
|
||||
|
||||
form_layout.addRow("Modo:", self.vlan_mode_combo)
|
||||
form_layout.addRow("Interfaz:", self.vlan_iface_combo)
|
||||
form_layout.addRow("VLAN ID(s):", self.vlan_ids_input)
|
||||
form_layout.addRow("", create_btn)
|
||||
|
||||
layout.addWidget(form_group)
|
||||
|
||||
# Tabla resumen VLANs
|
||||
self.vlan_table = QTableWidget()
|
||||
self.vlan_table.setColumnCount(5)
|
||||
self.vlan_table.setHorizontalHeaderLabels(
|
||||
["Interface", "VLAN ID", "Modo", "Detalle", "Acción"]
|
||||
)
|
||||
self.vlan_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
layout.addWidget(self.vlan_table)
|
||||
|
||||
self.tabs.addTab(tab, "VLANs")
|
||||
|
||||
def parse_vlan_ids(self, text: str):
|
||||
ids = set()
|
||||
for part in text.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "-" in part:
|
||||
start, end = part.split("-", 1)
|
||||
try:
|
||||
s = int(start)
|
||||
e = int(end)
|
||||
except ValueError:
|
||||
continue
|
||||
for vid in range(s, e + 1):
|
||||
ids.add(vid)
|
||||
else:
|
||||
try:
|
||||
ids.add(int(part))
|
||||
except ValueError:
|
||||
continue
|
||||
return sorted(ids)
|
||||
|
||||
def on_create_vlans_clicked(self):
|
||||
iface = self.vlan_iface_combo.currentText()
|
||||
vlan_text = self.vlan_ids_input.text().strip()
|
||||
if not iface or not vlan_text:
|
||||
QMessageBox.warning(self, "Error", "Selecciona interfaz y VLAN ID(s).")
|
||||
return
|
||||
|
||||
vlan_ids = self.parse_vlan_ids(vlan_text)
|
||||
if not vlan_ids:
|
||||
QMessageBox.warning(self, "Error", "Formato de VLAN ID(s) no válido.")
|
||||
return
|
||||
|
||||
mode = self.vlan_mode_combo.currentText()
|
||||
if "Trunk" in mode:
|
||||
ok, msg = self.router.create_vlan_trunk(iface, vlan_ids)
|
||||
else:
|
||||
if len(vlan_ids) != 1:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Error",
|
||||
"En modo Access sólo se permite un único VLAN ID.",
|
||||
)
|
||||
return
|
||||
ok, msg = self.router.create_vlan_access(iface, vlan_ids[0])
|
||||
|
||||
if ok:
|
||||
QMessageBox.information(self, "VLAN", msg)
|
||||
self.refresh_vlan_table()
|
||||
else:
|
||||
QMessageBox.critical(self, "Error VLAN", msg)
|
||||
|
||||
def refresh_vlan_table(self):
|
||||
status = self.router.get_routing_status()
|
||||
vlans = status.get("vlans", {})
|
||||
self.vlan_table.setRowCount(len(vlans))
|
||||
for row, (ifname, data) in enumerate(vlans.items()):
|
||||
self.vlan_table.setItem(row, 0, QTableWidgetItem(ifname))
|
||||
self.vlan_table.setItem(
|
||||
row, 1, QTableWidgetItem(str(data.get("vlan_id", "")))
|
||||
)
|
||||
self.vlan_table.setItem(row, 2, QTableWidgetItem(data.get("mode", "")))
|
||||
self.vlan_table.setItem(
|
||||
row,
|
||||
3,
|
||||
QTableWidgetItem(f"parent={data.get('parent', '')}"),
|
||||
)
|
||||
# Botón eliminar
|
||||
delete_btn = QPushButton("Eliminar")
|
||||
delete_btn.setStyleSheet("background-color: #800000; color: white; padding: 4px 8px;")
|
||||
delete_btn.clicked.connect(lambda checked, vlan_name=ifname: self.on_delete_vlan_clicked(vlan_name))
|
||||
self.vlan_table.setCellWidget(row, 4, delete_btn)
|
||||
|
||||
def on_delete_vlan_clicked(self, vlan_ifname: str):
|
||||
"""Elimina una VLAN tras confirmación del usuario."""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirmar eliminación",
|
||||
f"¿Estás seguro de que quieres eliminar la VLAN '{vlan_ifname}'?\n\n"
|
||||
"Esto eliminará la interfaz VLAN y limpiará todas las configuraciones asociadas.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
ok, msg = self.router.delete_vlan(vlan_ifname)
|
||||
if ok:
|
||||
QMessageBox.information(self, "VLAN eliminada", msg)
|
||||
self.refresh_vlan_table()
|
||||
# También refrescamos otras tablas que puedan verse afectadas
|
||||
self.refresh_redirection_table()
|
||||
self.refresh_networks_table()
|
||||
else:
|
||||
QMessageBox.critical(self, "Error al eliminar VLAN", msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Redirección de VLANs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_redirection_tab(self):
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
form_group = QGroupBox("Redirección de VLANs")
|
||||
form_layout = QFormLayout(form_group)
|
||||
|
||||
self.src_iface_combo = QComboBox()
|
||||
self.src_vlan_spin = QSpinBox()
|
||||
self.src_vlan_spin.setRange(1, 4094)
|
||||
self.src_vlan_spin.setSpecialValueText("Untagged")
|
||||
|
||||
self.dst_iface_combo = QComboBox()
|
||||
self.dst_vlan_spin = QSpinBox()
|
||||
self.dst_vlan_spin.setRange(1, 4094)
|
||||
self.dst_vlan_spin.setSpecialValueText("Untagged")
|
||||
|
||||
self.tagged_to_untagged_cb = QCheckBox("Forzar Tagged → Untagged")
|
||||
|
||||
redirect_btn = QPushButton("Crear redirección")
|
||||
redirect_btn.clicked.connect(self.on_create_redirection_clicked)
|
||||
|
||||
form_layout.addRow("Interfaz origen:", self.src_iface_combo)
|
||||
form_layout.addRow("VLAN origen (0 = untagged):", self.src_vlan_spin)
|
||||
form_layout.addRow("Interfaz destino:", self.dst_iface_combo)
|
||||
form_layout.addRow("VLAN destino (0 = untagged):", self.dst_vlan_spin)
|
||||
form_layout.addRow("", self.tagged_to_untagged_cb)
|
||||
form_layout.addRow("", redirect_btn)
|
||||
|
||||
layout.addWidget(form_group)
|
||||
|
||||
self.redir_table = QTableWidget()
|
||||
self.redir_table.setColumnCount(5)
|
||||
self.redir_table.setHorizontalHeaderLabels(
|
||||
["Origen", "VLAN Origen", "Destino", "VLAN Destino", "Bridge"]
|
||||
)
|
||||
self.redir_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
layout.addWidget(self.redir_table)
|
||||
|
||||
self.tabs.addTab(tab, "Redirección")
|
||||
|
||||
def on_create_redirection_clicked(self):
|
||||
src_iface = self.src_iface_combo.currentText()
|
||||
dst_iface = self.dst_iface_combo.currentText()
|
||||
src_vlan = self.src_vlan_spin.value() or None
|
||||
dst_vlan = self.dst_vlan_spin.value() or None
|
||||
|
||||
if not src_iface or not dst_iface:
|
||||
QMessageBox.warning(self, "Error", "Selecciona interfaces de origen y destino.")
|
||||
return
|
||||
|
||||
if self.tagged_to_untagged_cb.isChecked():
|
||||
# Forzamos patrón trunk -> access
|
||||
if src_vlan is None:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Error",
|
||||
"Para Tagged → Untagged la VLAN de origen no puede ser 0.",
|
||||
)
|
||||
return
|
||||
ok, msg = self.router.redirect_vlan_tagged_to_untagged(
|
||||
trunk_iface=src_iface, vlan_id=src_vlan, access_iface=dst_iface
|
||||
)
|
||||
else:
|
||||
ok, msg = self.router.redirect_vlan_between_interfaces(
|
||||
src_iface=src_iface,
|
||||
src_vlan_id=src_vlan,
|
||||
dst_iface=dst_iface,
|
||||
dst_vlan_id=dst_vlan,
|
||||
)
|
||||
|
||||
if ok:
|
||||
QMessageBox.information(self, "Redirección", msg)
|
||||
self.refresh_redirection_table()
|
||||
else:
|
||||
QMessageBox.critical(self, "Error redirección", msg)
|
||||
|
||||
def refresh_redirection_table(self):
|
||||
status = self.router.get_routing_status()
|
||||
rules = status.get("vlan_redirections", [])
|
||||
self.redir_table.setRowCount(len(rules))
|
||||
for row, r in enumerate(rules):
|
||||
self.redir_table.setItem(row, 0, QTableWidgetItem(r.get("src_iface", "")))
|
||||
self.redir_table.setItem(
|
||||
row,
|
||||
1,
|
||||
QTableWidgetItem(str(r.get("src_vlan", "") or "untagged")),
|
||||
)
|
||||
self.redir_table.setItem(row, 2, QTableWidgetItem(r.get("dst_iface", "")))
|
||||
self.redir_table.setItem(
|
||||
row,
|
||||
3,
|
||||
QTableWidgetItem(str(r.get("dst_vlan", "") or "untagged")),
|
||||
)
|
||||
self.redir_table.setItem(row, 4, QTableWidgetItem(r.get("bridge", "")))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Redes (Gateway / DHCP)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_networks_tab(self):
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
group = QGroupBox("Configuración de red por VLAN / interfaz")
|
||||
form = QFormLayout(group)
|
||||
|
||||
self.net_iface_combo = QComboBox()
|
||||
self.net_ip_input = QLineEdit()
|
||||
self.net_gw_input = QLineEdit()
|
||||
self.net_dns_input = QLineEdit()
|
||||
self.net_dhcp_cb = QCheckBox("Habilitar DHCP")
|
||||
self.net_dhcp_start = QLineEdit()
|
||||
self.net_dhcp_end = QLineEdit()
|
||||
|
||||
apply_btn = QPushButton("Aplicar configuración")
|
||||
apply_btn.clicked.connect(self.on_apply_network_config_clicked)
|
||||
|
||||
form.addRow("Interfaz / VLAN:", self.net_iface_combo)
|
||||
form.addRow("IP Gateway:", self.net_ip_input)
|
||||
form.addRow("Gateway (next-hop):", self.net_gw_input)
|
||||
form.addRow("DNS:", self.net_dns_input)
|
||||
form.addRow(self.net_dhcp_cb)
|
||||
form.addRow("DHCP Inicio:", self.net_dhcp_start)
|
||||
form.addRow("DHCP Fin:", self.net_dhcp_end)
|
||||
form.addRow("", apply_btn)
|
||||
|
||||
layout.addWidget(group)
|
||||
|
||||
self.net_table = QTableWidget()
|
||||
self.net_table.setColumnCount(6)
|
||||
self.net_table.setHorizontalHeaderLabels(
|
||||
["Interfaz", "IP", "Gateway", "DHCP", "Rango", "DNS"]
|
||||
)
|
||||
self.net_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
layout.addWidget(self.net_table)
|
||||
|
||||
self.tabs.addTab(tab, "Redes")
|
||||
|
||||
def on_apply_network_config_clicked(self):
|
||||
iface = self.net_iface_combo.currentText()
|
||||
ip = self.net_ip_input.text().strip()
|
||||
gw = self.net_gw_input.text().strip()
|
||||
dns = self.net_dns_input.text().strip() or None
|
||||
dhcp = self.net_dhcp_cb.isChecked()
|
||||
dhcp_start = self.net_dhcp_start.text().strip()
|
||||
dhcp_end = self.net_dhcp_end.text().strip()
|
||||
|
||||
if not iface or not ip:
|
||||
QMessageBox.warning(
|
||||
self, "Error", "Interfaz y IP son obligatorios para la red."
|
||||
)
|
||||
return
|
||||
|
||||
if gw:
|
||||
ok, msg = self.router.set_gateway_for_network(iface, ip, gw)
|
||||
if not ok:
|
||||
QMessageBox.critical(self, "Error gateway", msg)
|
||||
return
|
||||
|
||||
if dhcp:
|
||||
if not (dhcp_start and dhcp_end):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Error",
|
||||
"Para DHCP debes indicar rango de inicio y fin.",
|
||||
)
|
||||
return
|
||||
ok, msg = self.router.start_dhcp_server_for_vlan(
|
||||
vlan_interface=iface,
|
||||
ip=ip,
|
||||
range_start=dhcp_start,
|
||||
range_end=dhcp_end,
|
||||
gateway=gw or None,
|
||||
dns=dns,
|
||||
)
|
||||
if not ok:
|
||||
QMessageBox.critical(self, "Error DHCP", msg)
|
||||
return
|
||||
|
||||
QMessageBox.information(self, "Red", "Configuración aplicada.")
|
||||
self.refresh_networks_table()
|
||||
|
||||
def refresh_networks_table(self):
|
||||
status = self.router.get_routing_status()
|
||||
gws = status.get("gateways", {})
|
||||
dhcps = status.get("dhcp_servers", {})
|
||||
self.net_table.setRowCount(len(gws))
|
||||
for row, (iface, data) in enumerate(gws.items()):
|
||||
ip = data.get("ip", "")
|
||||
gw = data.get("gateway", "")
|
||||
dns = data.get("dns", "")
|
||||
dh = dhcps.get(iface)
|
||||
dh_enabled = "Sí" if dh else "No"
|
||||
dh_range = ""
|
||||
if dh:
|
||||
dh_range = f"{dh.get('range_start','')} - {dh.get('range_end','')}"
|
||||
|
||||
self.net_table.setItem(row, 0, QTableWidgetItem(iface))
|
||||
self.net_table.setItem(row, 1, QTableWidgetItem(ip))
|
||||
self.net_table.setItem(row, 2, QTableWidgetItem(gw))
|
||||
self.net_table.setItem(row, 3, QTableWidgetItem(dh_enabled))
|
||||
self.net_table.setItem(row, 4, QTableWidgetItem(dh_range))
|
||||
self.net_table.setItem(row, 5, QTableWidgetItem(dns))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Routing (rutas + reglas)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_routing_tab(self):
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
group = QGroupBox("Rutas estáticas")
|
||||
form = QFormLayout(group)
|
||||
|
||||
self.route_dst_input = QLineEdit()
|
||||
self.route_dst_input.setPlaceholderText("Ej: 10.0.0.0/24")
|
||||
self.route_gw_input = QLineEdit()
|
||||
self.route_iface_combo = QComboBox()
|
||||
self.route_table_spin = QSpinBox()
|
||||
self.route_table_spin.setRange(1, 252)
|
||||
self.route_table_spin.setValue(254)
|
||||
self.route_metric_spin = QSpinBox()
|
||||
self.route_metric_spin.setRange(1, 1000)
|
||||
self.route_metric_spin.setValue(100)
|
||||
|
||||
add_btn = QPushButton("Añadir ruta")
|
||||
add_btn.clicked.connect(self.on_add_route_clicked)
|
||||
|
||||
form.addRow("Destino:", self.route_dst_input)
|
||||
form.addRow("Gateway:", self.route_gw_input)
|
||||
form.addRow("Interfaz:", self.route_iface_combo)
|
||||
form.addRow("Tabla:", self.route_table_spin)
|
||||
form.addRow("Métrica:", self.route_metric_spin)
|
||||
form.addRow("", add_btn)
|
||||
|
||||
layout.addWidget(group)
|
||||
|
||||
self.routes_table = QTableWidget()
|
||||
self.routes_table.setColumnCount(5)
|
||||
self.routes_table.setHorizontalHeaderLabels(
|
||||
["Destino", "Gateway", "Interfaz", "Tabla", "Métrica"]
|
||||
)
|
||||
self.routes_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
layout.addWidget(self.routes_table)
|
||||
|
||||
fw_group = QGroupBox("Reglas de routing (policy-based)")
|
||||
fw_form = QFormLayout(fw_group)
|
||||
|
||||
self.rule_table_spin = QSpinBox()
|
||||
self.rule_table_spin.setRange(1, 252)
|
||||
self.rule_table_spin.setValue(100)
|
||||
self.rule_prio_spin = QSpinBox()
|
||||
self.rule_prio_spin.setRange(1, 32765)
|
||||
self.rule_prio_spin.setValue(32000)
|
||||
self.rule_src_input = QLineEdit()
|
||||
self.rule_dst_input = QLineEdit()
|
||||
self.rule_iif_input = QLineEdit()
|
||||
self.rule_oif_input = QLineEdit()
|
||||
add_rule_btn = QPushButton("Añadir regla")
|
||||
add_rule_btn.clicked.connect(self.on_add_rule_clicked)
|
||||
|
||||
fw_form.addRow("Tabla:", self.rule_table_spin)
|
||||
fw_form.addRow("Prioridad:", self.rule_prio_spin)
|
||||
fw_form.addRow("Origen (CIDR):", self.rule_src_input)
|
||||
fw_form.addRow("Destino (CIDR):", self.rule_dst_input)
|
||||
fw_form.addRow("Interfaz entrada:", self.rule_iif_input)
|
||||
fw_form.addRow("Interfaz salida:", self.rule_oif_input)
|
||||
fw_form.addRow("", add_rule_btn)
|
||||
|
||||
layout.addWidget(fw_group)
|
||||
|
||||
enable_fwd_btn = QPushButton("Habilitar IP forwarding")
|
||||
enable_fwd_btn.clicked.connect(self.on_enable_forwarding_clicked)
|
||||
layout.addWidget(enable_fwd_btn)
|
||||
|
||||
self.tabs.addTab(tab, "Routing")
|
||||
|
||||
def on_add_route_clicked(self):
|
||||
dst = self.route_dst_input.text().strip()
|
||||
gw = self.route_gw_input.text().strip()
|
||||
iface = self.route_iface_combo.currentText()
|
||||
table = self.route_table_spin.value()
|
||||
metric = self.route_metric_spin.value()
|
||||
|
||||
if not (dst and gw and iface):
|
||||
QMessageBox.warning(
|
||||
self, "Error", "Destino, gateway e interfaz son obligatorios."
|
||||
)
|
||||
return
|
||||
|
||||
ok, msg = self.router.add_route(
|
||||
dst=dst, gateway=gw, interface=iface, table=table, metric=metric
|
||||
)
|
||||
if ok:
|
||||
QMessageBox.information(self, "Ruta", msg)
|
||||
self.refresh_routes_table()
|
||||
else:
|
||||
QMessageBox.critical(self, "Error ruta", msg)
|
||||
|
||||
def on_add_rule_clicked(self):
|
||||
table = self.rule_table_spin.value()
|
||||
prio = self.rule_prio_spin.value()
|
||||
src = self.rule_src_input.text().strip() or None
|
||||
dst = self.rule_dst_input.text().strip() or None
|
||||
iif = self.rule_iif_input.text().strip() or None
|
||||
oif = self.rule_oif_input.text().strip() or None
|
||||
|
||||
ok, msg = self.router.add_routing_rule(
|
||||
table=table, priority=prio, src=src, dst=dst, iifname=iif, oifname=oif
|
||||
)
|
||||
if ok:
|
||||
QMessageBox.information(self, "Regla", msg)
|
||||
else:
|
||||
QMessageBox.critical(self, "Error regla", msg)
|
||||
|
||||
def on_enable_forwarding_clicked(self):
|
||||
ok, msg = self.router.enable_ip_forwarding()
|
||||
if ok:
|
||||
QMessageBox.information(self, "IP forwarding", msg)
|
||||
else:
|
||||
QMessageBox.critical(self, "Error", msg)
|
||||
|
||||
def refresh_routes_table(self):
|
||||
routes = self.router.list_routes()
|
||||
self.routes_table.setRowCount(len(routes))
|
||||
for row, r in enumerate(routes):
|
||||
self.routes_table.setItem(row, 0, QTableWidgetItem(r.get("dst", "")))
|
||||
self.routes_table.setItem(row, 1, QTableWidgetItem(r.get("gateway", "")))
|
||||
self.routes_table.setItem(row, 2, QTableWidgetItem(r.get("interface", "")))
|
||||
self.routes_table.setItem(
|
||||
row, 3, QTableWidgetItem(str(r.get("table", "")))
|
||||
)
|
||||
self.routes_table.setItem(
|
||||
row, 4, QTableWidgetItem(str(r.get("metric", "")))
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NAT
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_nat_tab(self):
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
group = QGroupBox("NAT y Port Forwarding")
|
||||
form = QFormLayout(group)
|
||||
|
||||
self.nat_internal_iface_combo = QComboBox()
|
||||
self.nat_external_iface_combo = QComboBox()
|
||||
enable_nat_btn = QPushButton("Habilitar NAT")
|
||||
enable_nat_btn.clicked.connect(self.on_enable_nat_clicked)
|
||||
|
||||
self.pf_ext_ip = QLineEdit()
|
||||
self.pf_ext_port = QSpinBox()
|
||||
self.pf_ext_port.setRange(1, 65535)
|
||||
self.pf_int_ip = QLineEdit()
|
||||
self.pf_int_port = QSpinBox()
|
||||
self.pf_int_port.setRange(1, 65535)
|
||||
self.pf_proto_combo = QComboBox()
|
||||
self.pf_proto_combo.addItems(["tcp", "udp"])
|
||||
add_pf_btn = QPushButton("Añadir port forwarding")
|
||||
add_pf_btn.clicked.connect(self.on_add_pf_clicked)
|
||||
|
||||
form.addRow("Interfaz interna:", self.nat_internal_iface_combo)
|
||||
form.addRow("Interfaz externa:", self.nat_external_iface_combo)
|
||||
form.addRow("", enable_nat_btn)
|
||||
form.addRow(QLabel("— Port Forwarding —"))
|
||||
form.addRow("IP externa:", self.pf_ext_ip)
|
||||
form.addRow("Puerto externo:", self.pf_ext_port)
|
||||
form.addRow("IP interna:", self.pf_int_ip)
|
||||
form.addRow("Puerto interno:", self.pf_int_port)
|
||||
form.addRow("Protocolo:", self.pf_proto_combo)
|
||||
form.addRow("", add_pf_btn)
|
||||
|
||||
layout.addWidget(group)
|
||||
|
||||
self.pf_table = QTableWidget()
|
||||
self.pf_table.setColumnCount(4)
|
||||
self.pf_table.setHorizontalHeaderLabels(
|
||||
["Externo", "Interno", "Proto", "Estado"]
|
||||
)
|
||||
self.pf_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
layout.addWidget(self.pf_table)
|
||||
|
||||
self.tabs.addTab(tab, "NAT")
|
||||
|
||||
def on_enable_nat_clicked(self):
|
||||
internal = self.nat_internal_iface_combo.currentText()
|
||||
external = self.nat_external_iface_combo.currentText()
|
||||
if not (internal and external):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Error",
|
||||
"Selecciona interfaz interna y externa para habilitar NAT.",
|
||||
)
|
||||
return
|
||||
ok, msg = self.router.enable_nat_for_interface(
|
||||
interface=internal, external_iface=external
|
||||
)
|
||||
if ok:
|
||||
QMessageBox.information(self, "NAT", msg)
|
||||
else:
|
||||
QMessageBox.critical(self, "Error NAT", msg)
|
||||
|
||||
def on_add_pf_clicked(self):
|
||||
ext_ip = self.pf_ext_ip.text().strip()
|
||||
ext_port = self.pf_ext_port.value()
|
||||
int_ip = self.pf_int_ip.text().strip()
|
||||
int_port = self.pf_int_port.value()
|
||||
proto = self.pf_proto_combo.currentText()
|
||||
|
||||
if not (ext_ip and int_ip):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Error",
|
||||
"Debes indicar IP externa e interna para el port forwarding.",
|
||||
)
|
||||
return
|
||||
|
||||
ok, msg = self.router.add_port_forwarding(
|
||||
external_ip=ext_ip,
|
||||
external_port=ext_port,
|
||||
internal_ip=int_ip,
|
||||
internal_port=int_port,
|
||||
protocol=proto,
|
||||
)
|
||||
if ok:
|
||||
QMessageBox.information(self, "Port forwarding", msg)
|
||||
self.refresh_pf_table()
|
||||
else:
|
||||
QMessageBox.critical(self, "Error port forwarding", msg)
|
||||
|
||||
def refresh_pf_table(self):
|
||||
status = self.router.get_routing_status()
|
||||
rules = status.get("nat_rules", [])
|
||||
# Sólo mostramos las que tienen campos de port forwarding
|
||||
pf_rules = [r for r in rules if "external_ip" in r]
|
||||
self.pf_table.setRowCount(len(pf_rules))
|
||||
for row, r in enumerate(pf_rules):
|
||||
self.pf_table.setItem(
|
||||
row,
|
||||
0,
|
||||
QTableWidgetItem(
|
||||
f"{r.get('external_ip','')}:{r.get('external_port','')}"
|
||||
),
|
||||
)
|
||||
self.pf_table.setItem(
|
||||
row,
|
||||
1,
|
||||
QTableWidgetItem(
|
||||
f"{r.get('internal_ip','')}:{r.get('internal_port','')}"
|
||||
),
|
||||
)
|
||||
self.pf_table.setItem(row, 2, QTableWidgetItem(r.get("protocol", "")))
|
||||
self.pf_table.setItem(row, 3, QTableWidgetItem("Activo"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Firewall / Visibilidad
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_firewall_tab(self):
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
group = QGroupBox("Visibilidad entre VLANs (simple)")
|
||||
form = QFormLayout(group)
|
||||
|
||||
self.fw_src_vlan = QSpinBox()
|
||||
self.fw_src_vlan.setRange(1, 4094)
|
||||
self.fw_dst_vlan = QSpinBox()
|
||||
self.fw_dst_vlan.setRange(1, 4094)
|
||||
self.fw_allow_cb = QCheckBox("Permitir tráfico (si no, se bloquea)")
|
||||
self.fw_allow_cb.setChecked(True)
|
||||
apply_btn = QPushButton("Aplicar política")
|
||||
apply_btn.clicked.connect(self.on_apply_vlan_visibility_clicked)
|
||||
|
||||
form.addRow("VLAN origen:", self.fw_src_vlan)
|
||||
form.addRow("VLAN destino:", self.fw_dst_vlan)
|
||||
form.addRow(self.fw_allow_cb)
|
||||
form.addRow("", apply_btn)
|
||||
|
||||
layout.addWidget(group)
|
||||
|
||||
self.fw_table = QTableWidget()
|
||||
self.fw_table.setColumnCount(3)
|
||||
self.fw_table.setHorizontalHeaderLabels(["VLAN origen", "VLAN destino", "Permitido"])
|
||||
self.fw_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
layout.addWidget(self.fw_table)
|
||||
|
||||
self.tabs.addTab(tab, "Firewall")
|
||||
|
||||
def on_apply_vlan_visibility_clicked(self):
|
||||
src = self.fw_src_vlan.value()
|
||||
dst = self.fw_dst_vlan.value()
|
||||
allow = self.fw_allow_cb.isChecked()
|
||||
|
||||
if allow:
|
||||
ok, msg = self.router.allow_vlan_to_vlan(src, dst)
|
||||
else:
|
||||
ok, msg = self.router.block_vlan_to_vlan(src, dst)
|
||||
|
||||
if ok:
|
||||
QMessageBox.information(self, "Firewall", msg)
|
||||
self.refresh_fw_table()
|
||||
else:
|
||||
QMessageBox.critical(self, "Error firewall", msg)
|
||||
|
||||
def refresh_fw_table(self):
|
||||
status = self.router.get_routing_status()
|
||||
vis = status.get("vlan_visibility", {})
|
||||
items = list(vis.items())
|
||||
self.fw_table.setRowCount(len(items))
|
||||
for row, (k, allowed) in enumerate(items):
|
||||
src_str, dst_str = k.split("-")
|
||||
self.fw_table.setItem(row, 0, QTableWidgetItem(src_str))
|
||||
self.fw_table.setItem(row, 1, QTableWidgetItem(dst_str))
|
||||
self.fw_table.setItem(row, 2, QTableWidgetItem("Sí" if allowed else "No"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utilidades comunes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def refresh_interfaces(self):
|
||||
interfaces = self.nm.list_interfaces()
|
||||
names = [i["name"] for i in interfaces]
|
||||
|
||||
combos = [
|
||||
self.vlan_iface_combo,
|
||||
self.src_iface_combo,
|
||||
self.dst_iface_combo,
|
||||
self.net_iface_combo,
|
||||
self.route_iface_combo,
|
||||
self.nat_internal_iface_combo,
|
||||
self.nat_external_iface_combo,
|
||||
]
|
||||
|
||||
for combo in combos:
|
||||
combo.clear()
|
||||
combo.addItems(names)
|
||||
|
||||
# Tras refrescar interfaces, actualizamos también tablas en blanco
|
||||
self.refresh_vlan_table()
|
||||
self.refresh_redirection_table()
|
||||
self.refresh_networks_table()
|
||||
self.refresh_routes_table()
|
||||
self.refresh_pf_table()
|
||||
self.refresh_fw_table()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
||||
QLabel, QHBoxLayout, QFrame, QPushButton, QStyle)
|
||||
QLabel, QHBoxLayout, QFrame, QPushButton, QStyle, QMessageBox)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QIcon, QColor, QFont
|
||||
|
||||
@@ -18,6 +18,14 @@ class InterfaceListWidget(QWidget):
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch()
|
||||
|
||||
# Reset Button
|
||||
self.reset_btn = QPushButton("Reset")
|
||||
self.reset_btn.setToolTip("Reiniciar interfaz seleccionada (down → up)")
|
||||
self.reset_btn.setStyleSheet("background-color: #ff9800; color: white; padding: 4px 12px; font-weight: 600;")
|
||||
self.reset_btn.clicked.connect(self.on_reset_clicked)
|
||||
self.reset_btn.setEnabled(False) # Deshabilitado hasta que se seleccione una interfaz
|
||||
header_layout.addWidget(self.reset_btn)
|
||||
|
||||
# Refresh Button
|
||||
self.refresh_btn = QPushButton()
|
||||
self.refresh_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload))
|
||||
@@ -28,6 +36,9 @@ class InterfaceListWidget(QWidget):
|
||||
|
||||
self.layout.addLayout(header_layout)
|
||||
|
||||
# Guardar la interfaz seleccionada actual
|
||||
self.current_selected_iface = None
|
||||
|
||||
# List
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.itemClicked.connect(self.on_item_clicked)
|
||||
@@ -97,4 +108,33 @@ class InterfaceListWidget(QWidget):
|
||||
|
||||
def on_item_clicked(self, item):
|
||||
iface_data = item.data(Qt.ItemDataRole.UserRole)
|
||||
self.current_selected_iface = iface_data
|
||||
self.reset_btn.setEnabled(True) # Habilitar botón reset cuando hay una interfaz seleccionada
|
||||
self.interface_selected.emit(iface_data)
|
||||
|
||||
def on_reset_clicked(self):
|
||||
"""Reinicia la interfaz seleccionada."""
|
||||
if not self.current_selected_iface:
|
||||
QMessageBox.warning(self, "Error", "No hay interfaz seleccionada.")
|
||||
return
|
||||
|
||||
iface_name = self.current_selected_iface['name']
|
||||
|
||||
# Confirmación
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirmar reinicio",
|
||||
f"¿Estás seguro de que quieres reiniciar la interfaz '{iface_name}'?\n\n"
|
||||
"Esto hará un down y luego up de la interfaz. La conexión se interrumpirá brevemente.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
success, msg = self.nm.reset_interface(iface_name)
|
||||
if success:
|
||||
QMessageBox.information(self, "Interfaz reiniciada", msg)
|
||||
# Refrescar la lista para mostrar el nuevo estado
|
||||
self.refresh_interfaces()
|
||||
else:
|
||||
QMessageBox.critical(self, "Error", msg)
|
||||
@@ -0,0 +1,177 @@
|
||||
import subprocess
|
||||
from typing import Dict
|
||||
|
||||
import psutil
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QHeaderView,
|
||||
QGroupBox,
|
||||
)
|
||||
|
||||
from src.core.network_manager import NetworkManager
|
||||
|
||||
|
||||
class NetworkMonitorWidget(QWidget):
|
||||
"""
|
||||
Monitor de red en tiempo real:
|
||||
- Tráfico por interfaz (bytes/seg, paquetes)
|
||||
- Resumen básico de reglas de firewall (iptables FORWARD)
|
||||
"""
|
||||
|
||||
def __init__(self, network_manager: NetworkManager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.nm = network_manager
|
||||
|
||||
self.last_counters: Dict[str, psutil._common.snetio] = {}
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
title = QLabel("Monitor de Red")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
title.setStyleSheet("font-size: 18px; font-weight: 600;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Grupo de tráfico por interfaz
|
||||
traffic_group = QGroupBox("Tráfico por interfaz")
|
||||
traffic_layout = QVBoxLayout(traffic_group)
|
||||
|
||||
self.iface_table = QTableWidget()
|
||||
self.iface_table.setColumnCount(7)
|
||||
self.iface_table.setHorizontalHeaderLabels(
|
||||
[
|
||||
"Interfaz",
|
||||
"RX total (MB)",
|
||||
"TX total (MB)",
|
||||
"RX (KB/s)",
|
||||
"TX (KB/s)",
|
||||
"Paquetes RX/s",
|
||||
"Paquetes TX/s",
|
||||
]
|
||||
)
|
||||
self.iface_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
traffic_layout.addWidget(self.iface_table)
|
||||
|
||||
layout.addWidget(traffic_group)
|
||||
|
||||
# Grupo de resumen de firewall
|
||||
fw_group = QGroupBox("Resumen de firewall (iptables FORWARD)")
|
||||
fw_layout = QVBoxLayout(fw_group)
|
||||
|
||||
self.fw_table = QTableWidget()
|
||||
self.fw_table.setColumnCount(5)
|
||||
self.fw_table.setHorizontalHeaderLabels(
|
||||
["Regla", "Política", "Paquetes", "Bytes", "Destino"]
|
||||
)
|
||||
self.fw_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
fw_layout.addWidget(self.fw_table)
|
||||
|
||||
layout.addWidget(fw_group)
|
||||
|
||||
# Temporizador de actualización
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(1000) # 1 segundo
|
||||
self.timer.timeout.connect(self.refresh)
|
||||
self.timer.start()
|
||||
|
||||
# Inicial
|
||||
self.refresh(initial=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actualización
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def refresh(self, initial: bool = False):
|
||||
self.refresh_traffic(initial=initial)
|
||||
self.refresh_firewall()
|
||||
|
||||
def refresh_traffic(self, initial: bool = False):
|
||||
counters = psutil.net_io_counters(pernic=True)
|
||||
|
||||
# Aseguramos que la tabla tenga una fila por interfaz conocida por NetworkManager
|
||||
interfaces = [iface["name"] for iface in self.nm.list_interfaces()]
|
||||
|
||||
self.iface_table.setRowCount(len(interfaces))
|
||||
|
||||
for row, name in enumerate(interfaces):
|
||||
data = counters.get(name)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
last = self.last_counters.get(name)
|
||||
if last and not initial:
|
||||
seconds = 1.0 # timer fijo a 1s
|
||||
rx_rate = (data.bytes_recv - last.bytes_recv) / 1024.0 / seconds
|
||||
tx_rate = (data.bytes_sent - last.bytes_sent) / 1024.0 / seconds
|
||||
rx_pkts = (data.packets_recv - last.packets_recv) / seconds
|
||||
tx_pkts = (data.packets_sent - last.packets_sent) / seconds
|
||||
else:
|
||||
rx_rate = tx_rate = rx_pkts = tx_pkts = 0.0
|
||||
|
||||
self.iface_table.setItem(row, 0, QTableWidgetItem(name))
|
||||
self.iface_table.setItem(
|
||||
row, 1, QTableWidgetItem(f"{data.bytes_recv / (1024.0 * 1024.0):.2f}")
|
||||
)
|
||||
self.iface_table.setItem(
|
||||
row, 2, QTableWidgetItem(f"{data.bytes_sent / (1024.0 * 1024.0):.2f}")
|
||||
)
|
||||
self.iface_table.setItem(row, 3, QTableWidgetItem(f"{rx_rate:.1f}"))
|
||||
self.iface_table.setItem(row, 4, QTableWidgetItem(f"{tx_rate:.1f}"))
|
||||
self.iface_table.setItem(row, 5, QTableWidgetItem(f"{rx_pkts:.1f}"))
|
||||
self.iface_table.setItem(row, 6, QTableWidgetItem(f"{tx_pkts:.1f}"))
|
||||
|
||||
self.last_counters = counters
|
||||
|
||||
def refresh_firewall(self):
|
||||
"""
|
||||
Lee iptables FORWARD para mostrar paquetes/bytes aceptados y bloqueados.
|
||||
Si iptables no está disponible, deja la tabla vacía.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["iptables", "-L", "FORWARD", "-v", "-n"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
self.fw_table.setRowCount(0)
|
||||
return
|
||||
|
||||
lines = result.stdout.splitlines()
|
||||
rules = []
|
||||
# Saltamos las dos primeras líneas de cabecera
|
||||
for line in lines[2:]:
|
||||
parts = line.split()
|
||||
if len(parts) < 8:
|
||||
continue
|
||||
pkts = parts[0]
|
||||
bytes_ = parts[1]
|
||||
target = parts[2]
|
||||
# parts[3] prot, parts[4] opt, parts[5] in, parts[6] out, parts[7] source, parts[8] dest (si existe)
|
||||
dest = parts[8] if len(parts) > 8 else ""
|
||||
rules.append(
|
||||
{
|
||||
"target": target,
|
||||
"pkts": pkts,
|
||||
"bytes": bytes_,
|
||||
"dest": dest,
|
||||
}
|
||||
)
|
||||
|
||||
self.fw_table.setRowCount(len(rules))
|
||||
for row, r in enumerate(rules):
|
||||
self.fw_table.setItem(row, 0, QTableWidgetItem(f"Regla {row + 1}"))
|
||||
self.fw_table.setItem(row, 1, QTableWidgetItem(r["target"]))
|
||||
self.fw_table.setItem(row, 2, QTableWidgetItem(r["pkts"]))
|
||||
self.fw_table.setItem(row, 3, QTableWidgetItem(r["bytes"]))
|
||||
self.fw_table.setItem(row, 4, QTableWidgetItem(r["dest"]))
|
||||
@@ -0,0 +1,522 @@
|
||||
import datetime
|
||||
import os
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QHeaderView,
|
||||
QGroupBox,
|
||||
QFormLayout,
|
||||
QLineEdit,
|
||||
QSpinBox,
|
||||
QComboBox,
|
||||
QMessageBox,
|
||||
QFileDialog,
|
||||
QTextEdit,
|
||||
QCheckBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QColor
|
||||
|
||||
from src.core.vlan35_checker import VLAN35Checker
|
||||
|
||||
|
||||
class VLAN35CheckWidget(QWidget):
|
||||
"""
|
||||
Widget para comprobación de visibilidad en VLAN 35.
|
||||
Ejecuta tests de conectividad TCP/UDP y genera informes HTML completos.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.checker = VLAN35Checker()
|
||||
self.test_thread = None
|
||||
self.test_results = []
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
title = QLabel("Comprobación VLAN35")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
title.setStyleSheet("font-size: 18px; font-weight: 600;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Grupo de configuración de tests
|
||||
config_group = QGroupBox("Configuración de Tests")
|
||||
config_layout = QVBoxLayout(config_group)
|
||||
|
||||
# Botones de acción rápida
|
||||
action_layout = QHBoxLayout()
|
||||
load_defaults_btn = QPushButton("Cargar Tests por Defecto")
|
||||
load_defaults_btn.clicked.connect(self.load_default_tests)
|
||||
action_layout.addWidget(load_defaults_btn)
|
||||
|
||||
self.run_btn = QPushButton("Ejecutar Tests")
|
||||
self.run_btn.setStyleSheet("background-color: #008542; color: white; padding: 6px 16px;")
|
||||
self.run_btn.clicked.connect(self.run_tests)
|
||||
action_layout.addWidget(self.run_btn)
|
||||
|
||||
self.stop_btn = QPushButton("Detener")
|
||||
self.stop_btn.setEnabled(False)
|
||||
self.stop_btn.setStyleSheet("background-color: #800000; color: white;")
|
||||
self.stop_btn.clicked.connect(self.stop_tests)
|
||||
action_layout.addWidget(self.stop_btn)
|
||||
|
||||
action_layout.addStretch()
|
||||
|
||||
generate_report_btn = QPushButton("Generar Informe HTML")
|
||||
generate_report_btn.clicked.connect(self.generate_report)
|
||||
action_layout.addWidget(generate_report_btn)
|
||||
|
||||
config_layout.addLayout(action_layout)
|
||||
|
||||
# Tabla de tests configurados
|
||||
self.tests_table = QTableWidget()
|
||||
self.tests_table.setColumnCount(5)
|
||||
self.tests_table.setHorizontalHeaderLabels(
|
||||
["Host", "Puerto", "Protocolo", "Tipo", "Acción"]
|
||||
)
|
||||
self.tests_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
config_layout.addWidget(self.tests_table)
|
||||
|
||||
# Formulario para añadir nuevo test
|
||||
form_group = QGroupBox("Añadir Nuevo Test")
|
||||
form_layout = QFormLayout(form_group)
|
||||
|
||||
self.new_host_input = QLineEdit()
|
||||
self.new_host_input.setPlaceholderText("10.47.8.133")
|
||||
self.new_port_spin = QSpinBox()
|
||||
self.new_port_spin.setRange(1, 65535)
|
||||
self.new_port_spin.setValue(80)
|
||||
self.new_proto_combo = QComboBox()
|
||||
self.new_proto_combo.addItems(["TCP", "UDP"])
|
||||
self.new_type_combo = QComboBox()
|
||||
self.new_type_combo.addItems(["connectivity", "ntp"])
|
||||
|
||||
add_test_btn = QPushButton("Añadir Test")
|
||||
add_test_btn.clicked.connect(self.add_test)
|
||||
|
||||
form_layout.addRow("Host:", self.new_host_input)
|
||||
form_layout.addRow("Puerto:", self.new_port_spin)
|
||||
form_layout.addRow("Protocolo:", self.new_proto_combo)
|
||||
form_layout.addRow("Tipo:", self.new_type_combo)
|
||||
form_layout.addRow("", add_test_btn)
|
||||
|
||||
config_layout.addWidget(form_group)
|
||||
layout.addWidget(config_group)
|
||||
|
||||
# Grupo de resultados
|
||||
results_group = QGroupBox("Resultados de Tests")
|
||||
results_layout = QVBoxLayout(results_group)
|
||||
|
||||
self.results_table = QTableWidget()
|
||||
self.results_table.setColumnCount(7)
|
||||
self.results_table.setHorizontalHeaderLabels(
|
||||
[
|
||||
"Host",
|
||||
"Puerto",
|
||||
"Protocolo",
|
||||
"Estado",
|
||||
"Latencia (ms)",
|
||||
"Detalles",
|
||||
"Timestamp",
|
||||
]
|
||||
)
|
||||
self.results_table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
results_layout.addWidget(self.results_table)
|
||||
|
||||
# Log de progreso
|
||||
self.log_output = QTextEdit()
|
||||
self.log_output.setReadOnly(True)
|
||||
self.log_output.setMaximumHeight(120)
|
||||
self.log_output.setStyleSheet(
|
||||
"background-color: #1e1e1e; color: #00ff00; font-family: monospace; font-size: 11px;"
|
||||
)
|
||||
results_layout.addWidget(self.log_output)
|
||||
|
||||
layout.addWidget(results_group)
|
||||
|
||||
# Cargar tests por defecto al inicio
|
||||
self.load_default_tests()
|
||||
|
||||
def load_default_tests(self):
|
||||
"""Carga los tests por defecto del checker."""
|
||||
tests = self.checker.get_default_tests()
|
||||
self.tests_table.setRowCount(len(tests))
|
||||
for row, test in enumerate(tests):
|
||||
self.tests_table.setItem(row, 0, QTableWidgetItem(test["host"]))
|
||||
self.tests_table.setItem(row, 1, QTableWidgetItem(str(test["port"])))
|
||||
self.tests_table.setItem(row, 2, QTableWidgetItem(test["protocol"]))
|
||||
self.tests_table.setItem(row, 3, QTableWidgetItem(test.get("type", "connectivity")))
|
||||
|
||||
delete_btn = QPushButton("Eliminar")
|
||||
delete_btn.setStyleSheet("background-color: #800000; color: white; padding: 2px 6px;")
|
||||
delete_btn.clicked.connect(
|
||||
lambda checked, r=row: self.remove_test(r)
|
||||
)
|
||||
self.tests_table.setCellWidget(row, 4, delete_btn)
|
||||
|
||||
def add_test(self):
|
||||
"""Añade un nuevo test a la tabla."""
|
||||
host = self.new_host_input.text().strip()
|
||||
port = self.new_port_spin.value()
|
||||
protocol = self.new_proto_combo.currentText()
|
||||
test_type = self.new_type_combo.currentText()
|
||||
|
||||
if not host:
|
||||
QMessageBox.warning(self, "Error", "Debes indicar un host.")
|
||||
return
|
||||
|
||||
row = self.tests_table.rowCount()
|
||||
self.tests_table.insertRow(row)
|
||||
self.tests_table.setItem(row, 0, QTableWidgetItem(host))
|
||||
self.tests_table.setItem(row, 1, QTableWidgetItem(str(port)))
|
||||
self.tests_table.setItem(row, 2, QTableWidgetItem(protocol))
|
||||
self.tests_table.setItem(row, 3, QTableWidgetItem(test_type))
|
||||
|
||||
delete_btn = QPushButton("Eliminar")
|
||||
delete_btn.setStyleSheet("background-color: #800000; color: white; padding: 2px 6px;")
|
||||
delete_btn.clicked.connect(lambda checked, r=row: self.remove_test(r))
|
||||
self.tests_table.setCellWidget(row, 4, delete_btn)
|
||||
|
||||
# Limpiar formulario
|
||||
self.new_host_input.clear()
|
||||
self.new_port_spin.setValue(80)
|
||||
|
||||
def remove_test(self, row: int):
|
||||
"""Elimina un test de la tabla."""
|
||||
self.tests_table.removeRow(row)
|
||||
|
||||
def get_tests_from_table(self) -> list:
|
||||
"""Extrae la lista de tests desde la tabla."""
|
||||
tests = []
|
||||
for row in range(self.tests_table.rowCount()):
|
||||
host = self.tests_table.item(row, 0)
|
||||
port = self.tests_table.item(row, 1)
|
||||
protocol = self.tests_table.item(row, 2)
|
||||
test_type = self.tests_table.item(row, 3)
|
||||
|
||||
if host and port and protocol:
|
||||
tests.append(
|
||||
{
|
||||
"host": host.text(),
|
||||
"port": int(port.text()),
|
||||
"protocol": protocol.text(),
|
||||
"type": test_type.text() if test_type else "connectivity",
|
||||
}
|
||||
)
|
||||
return tests
|
||||
|
||||
def run_tests(self):
|
||||
"""Ejecuta todos los tests configurados."""
|
||||
tests = self.get_tests_from_table()
|
||||
if not tests:
|
||||
QMessageBox.warning(self, "Error", "No hay tests configurados.")
|
||||
return
|
||||
|
||||
self.test_results = []
|
||||
self.results_table.setRowCount(0)
|
||||
self.log_output.clear()
|
||||
self.log_output.append(f"Iniciando {len(tests)} tests...\n")
|
||||
|
||||
self.run_btn.setEnabled(False)
|
||||
self.stop_btn.setEnabled(True)
|
||||
|
||||
self.test_thread = self.checker.run_tests(tests)
|
||||
self.test_thread.test_result.connect(self.on_test_result)
|
||||
self.test_thread.all_finished.connect(self.on_all_finished)
|
||||
|
||||
def stop_tests(self):
|
||||
"""Detiene los tests en ejecución."""
|
||||
if self.test_thread and self.test_thread.isRunning():
|
||||
self.test_thread.terminate()
|
||||
self.test_thread.wait()
|
||||
self.run_btn.setEnabled(True)
|
||||
self.stop_btn.setEnabled(False)
|
||||
self.log_output.append("\nTests detenidos por el usuario.")
|
||||
|
||||
def on_test_result(self, result: dict):
|
||||
"""Procesa el resultado de un test individual."""
|
||||
self.test_results.append(result)
|
||||
|
||||
row = self.results_table.rowCount()
|
||||
self.results_table.insertRow(row)
|
||||
|
||||
# Host
|
||||
self.results_table.setItem(row, 0, QTableWidgetItem(result["host"]))
|
||||
# Puerto
|
||||
self.results_table.setItem(row, 1, QTableWidgetItem(str(result["port"])))
|
||||
# Protocolo
|
||||
self.results_table.setItem(row, 2, QTableWidgetItem(result["protocol"]))
|
||||
# Estado
|
||||
status_item = QTableWidgetItem(result["status"])
|
||||
if result["success"]:
|
||||
status_item.setForeground(QColor("#4caf50"))
|
||||
else:
|
||||
status_item.setForeground(QColor("#f44336"))
|
||||
self.results_table.setItem(row, 3, status_item)
|
||||
# Latencia
|
||||
latency_text = (
|
||||
f"{result['latency_ms']:.2f}" if result["latency_ms"] is not None else "N/A"
|
||||
)
|
||||
self.results_table.setItem(row, 4, QTableWidgetItem(latency_text))
|
||||
# Detalles
|
||||
details = result.get("details", result.get("error", ""))
|
||||
self.results_table.setItem(row, 5, QTableWidgetItem(details))
|
||||
# Timestamp
|
||||
self.results_table.setItem(row, 6, QTableWidgetItem(result["timestamp"]))
|
||||
|
||||
# Log
|
||||
status_icon = "✓" if result["success"] else "✗"
|
||||
self.log_output.append(
|
||||
f"{status_icon} {result['host']}:{result['port']} ({result['protocol']}) - {result['status']}"
|
||||
)
|
||||
scrollbar = self.log_output.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
|
||||
def on_all_finished(self):
|
||||
"""Se ejecuta cuando todos los tests terminan."""
|
||||
self.run_btn.setEnabled(True)
|
||||
self.stop_btn.setEnabled(False)
|
||||
|
||||
success_count = sum(1 for r in self.test_results if r["success"])
|
||||
total_count = len(self.test_results)
|
||||
|
||||
self.log_output.append(
|
||||
f"\n{'='*50}\nTests completados: {success_count}/{total_count} exitosos"
|
||||
)
|
||||
|
||||
def generate_report(self):
|
||||
"""Genera un informe HTML completo con todos los resultados."""
|
||||
if not self.test_results:
|
||||
QMessageBox.warning(
|
||||
self, "Error", "No hay resultados para generar el informe. Ejecuta los tests primero."
|
||||
)
|
||||
return
|
||||
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self, "Guardar Informe", "vlan35_check_report.html", "HTML Files (*.html)"
|
||||
)
|
||||
|
||||
if not filename:
|
||||
return
|
||||
|
||||
html = self._generate_html_report()
|
||||
try:
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
QMessageBox.information(
|
||||
self, "Informe Generado", f"Informe guardado en:\n{os.path.abspath(filename)}"
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Error al guardar informe:\n{str(e)}")
|
||||
|
||||
def _generate_html_report(self) -> str:
|
||||
"""Genera el contenido HTML del informe."""
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Estadísticas
|
||||
total = len(self.test_results)
|
||||
success = sum(1 for r in self.test_results if r["success"])
|
||||
failed = total - success
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Informe de Comprobación VLAN35</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: 'Segoe UI', 'Roboto', sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
border-radius: 8px;
|
||||
}}
|
||||
h1 {{
|
||||
color: #008542;
|
||||
border-bottom: 3px solid #008542;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
h2 {{
|
||||
color: #444;
|
||||
margin-top: 30px;
|
||||
border-left: 5px solid #008542;
|
||||
padding-left: 10px;
|
||||
}}
|
||||
.header-info {{
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
.stats {{
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 15px;
|
||||
}}
|
||||
.stat-box {{
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}}
|
||||
.stat-success {{
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}}
|
||||
.stat-failed {{
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}}
|
||||
.stat-total {{
|
||||
background-color: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}}
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
font-size: 0.9em;
|
||||
}}
|
||||
th, td {{
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}}
|
||||
th {{
|
||||
background-color: #008542;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}}
|
||||
tr:nth-child(even) {{
|
||||
background-color: #f9f9f9;
|
||||
}}
|
||||
.status-success {{
|
||||
color: #2e7d32;
|
||||
font-weight: bold;
|
||||
}}
|
||||
.status-failed {{
|
||||
color: #c62828;
|
||||
font-weight: bold;
|
||||
}}
|
||||
.timestamp {{
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Informe de Comprobación VLAN35</h1>
|
||||
|
||||
<div class="header-info">
|
||||
<p><strong>Fecha y hora:</strong> {timestamp}</p>
|
||||
<p><strong>Total de tests ejecutados:</strong> {total}</p>
|
||||
<div class="stats">
|
||||
<div class="stat-box stat-success">
|
||||
<div style="font-size: 24px; font-weight: bold;">{success}</div>
|
||||
<div>Exitosos</div>
|
||||
</div>
|
||||
<div class="stat-box stat-failed">
|
||||
<div style="font-size: 24px; font-weight: bold;">{failed}</div>
|
||||
<div>Fallidos</div>
|
||||
</div>
|
||||
<div class="stat-box stat-total">
|
||||
<div style="font-size: 24px; font-weight: bold;">{total}</div>
|
||||
<div>Total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Resultados Detallados</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Puerto</th>
|
||||
<th>Protocolo</th>
|
||||
<th>Estado</th>
|
||||
<th>Latencia (ms)</th>
|
||||
<th>Detalles</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
for result in self.test_results:
|
||||
status_class = "status-success" if result["success"] else "status-failed"
|
||||
latency = (
|
||||
f"{result['latency_ms']:.2f}" if result["latency_ms"] is not None else "N/A"
|
||||
)
|
||||
details = result.get("details", result.get("error", ""))
|
||||
|
||||
html += f"""
|
||||
<tr>
|
||||
<td><strong>{result['host']}</strong></td>
|
||||
<td>{result['port']}</td>
|
||||
<td>{result['protocol']}</td>
|
||||
<td class="{status_class}">{result['status']}</td>
|
||||
<td>{latency}</td>
|
||||
<td>{details}</td>
|
||||
<td class="timestamp">{result['timestamp']}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</table>
|
||||
|
||||
<h2>Resumen por Host</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Tests Exitosos</th>
|
||||
<th>Tests Fallidos</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
# Agrupar por host
|
||||
host_stats = {}
|
||||
for result in self.test_results:
|
||||
host = result["host"]
|
||||
if host not in host_stats:
|
||||
host_stats[host] = {"success": 0, "failed": 0}
|
||||
if result["success"]:
|
||||
host_stats[host]["success"] += 1
|
||||
else:
|
||||
host_stats[host]["failed"] += 1
|
||||
|
||||
for host, stats in host_stats.items():
|
||||
total_host = stats["success"] + stats["failed"]
|
||||
html += f"""
|
||||
<tr>
|
||||
<td><strong>{host}</strong></td>
|
||||
<td class="status-success">{stats['success']}</td>
|
||||
<td class="status-failed">{stats['failed']}</td>
|
||||
<td>{total_host}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return html
|
||||
Binary file not shown.
@@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Informe de Comprobación VLAN35</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Roboto', sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
h1 {
|
||||
color: #008542;
|
||||
border-bottom: 3px solid #008542;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h2 {
|
||||
color: #444;
|
||||
margin-top: 30px;
|
||||
border-left: 5px solid #008542;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.header-info {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.stat-box {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-success {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.stat-failed {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
.stat-total {
|
||||
background-color: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #008542;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.status-success {
|
||||
color: #2e7d32;
|
||||
font-weight: bold;
|
||||
}
|
||||
.status-failed {
|
||||
color: #c62828;
|
||||
font-weight: bold;
|
||||
}
|
||||
.timestamp {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Informe de Comprobación VLAN35</h1>
|
||||
|
||||
<div class="header-info">
|
||||
<p><strong>Fecha y hora:</strong> 2026-02-16 11:10:26</p>
|
||||
<p><strong>Total de tests ejecutados:</strong> 6</p>
|
||||
<div class="stats">
|
||||
<div class="stat-box stat-success">
|
||||
<div style="font-size: 24px; font-weight: bold;">6</div>
|
||||
<div>Exitosos</div>
|
||||
</div>
|
||||
<div class="stat-box stat-failed">
|
||||
<div style="font-size: 24px; font-weight: bold;">0</div>
|
||||
<div>Fallidos</div>
|
||||
</div>
|
||||
<div class="stat-box stat-total">
|
||||
<div style="font-size: 24px; font-weight: bold;">6</div>
|
||||
<div>Total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Resultados Detallados</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Puerto</th>
|
||||
<th>Protocolo</th>
|
||||
<th>Estado</th>
|
||||
<th>Latencia (ms)</th>
|
||||
<th>Detalles</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><strong>10.47.8.133</strong></td>
|
||||
<td>515</td>
|
||||
<td>TCP</td>
|
||||
<td class="status-success">Open</td>
|
||||
<td>7.10</td>
|
||||
<td>Conexión TCP exitosa en 7.10ms</td>
|
||||
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><strong>10.47.8.133</strong></td>
|
||||
<td>9195</td>
|
||||
<td>TCP</td>
|
||||
<td class="status-success">Open</td>
|
||||
<td>7.88</td>
|
||||
<td>Conexión TCP exitosa en 7.88ms</td>
|
||||
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><strong>10.47.8.135</strong></td>
|
||||
<td>9191</td>
|
||||
<td>TCP</td>
|
||||
<td class="status-success">Open</td>
|
||||
<td>8.01</td>
|
||||
<td>Conexión TCP exitosa en 8.01ms</td>
|
||||
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><strong>10.47.8.135</strong></td>
|
||||
<td>9192</td>
|
||||
<td>TCP</td>
|
||||
<td class="status-success">Open</td>
|
||||
<td>4.41</td>
|
||||
<td>Conexión TCP exitosa en 4.41ms</td>
|
||||
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><strong>10.240.225.171</strong></td>
|
||||
<td>465</td>
|
||||
<td>TCP</td>
|
||||
<td class="status-success">Open</td>
|
||||
<td>3.96</td>
|
||||
<td>Conexión TCP exitosa en 3.96ms</td>
|
||||
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><strong>10.244.255.250</strong></td>
|
||||
<td>123</td>
|
||||
<td>UDP</td>
|
||||
<td class="status-success">Open</td>
|
||||
<td>5.11</td>
|
||||
<td>NTP hora local: 2026-02-16 11:25:01</td>
|
||||
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<h2>Resumen por Host</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Tests Exitosos</th>
|
||||
<th>Tests Fallidos</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><strong>10.47.8.133</strong></td>
|
||||
<td class="status-success">2</td>
|
||||
<td class="status-failed">0</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><strong>10.47.8.135</strong></td>
|
||||
<td class="status-success">2</td>
|
||||
<td class="status-failed">0</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><strong>10.240.225.171</strong></td>
|
||||
<td class="status-success">1</td>
|
||||
<td class="status-failed">0</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><strong>10.244.255.250</strong></td>
|
||||
<td class="status-success">1</td>
|
||||
<td class="status-failed">0</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user