JNC Windows

This commit is contained in:
joaquin.navajas
2026-05-11 07:56:47 +02:00
commit 7add14d06f
77 changed files with 42025 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
### AL ###
#Template for AL projects for Dynamics 365 Business Central
#launch.json folder
.vscode/
#Cache folder
.alcache/
#Symbols folder
.alpackages/
#Snapshots folder
.snapshots/
#Testing Output folder
.output/
#Extension App-file
*.app
#Rapid Application Development File
rad.json
#Translation Base-file
*.g.xlf
#License-file
*.flf
#Test results file
TestResults.xml
+80
View File
@@ -0,0 +1,80 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Genera el instalador (setup) de JNC-NetTools para instalar en el disco.
.DESCRIPTION
Asegura que exista el .exe, busca Inno Setup y compila el instalador.
El setup se genera en dist_installer\JNC-NetTools-Setup-1.0.exe
.EXAMPLE
.\Build-Setup.ps1
#>
$ErrorActionPreference = "Stop"
$ProjectRoot = $PSScriptRoot
Set-Location $ProjectRoot
Write-Host "=== JNC-NetTools - Generar Setup ===" -ForegroundColor Cyan
Write-Host ""
# 1. Comprobar que existe el ejecutable
$exePath = Join-Path $ProjectRoot "dist\JNC-NetTools.exe"
if (-not (Test-Path $exePath)) {
Write-Host "No se encontró dist\JNC-NetTools.exe. Ejecutando Build-Windows.ps1..." -ForegroundColor Yellow
& (Join-Path $ProjectRoot "Build-Windows.ps1")
if (-not (Test-Path $exePath)) {
Write-Host "ERROR: No se pudo generar el ejecutable." -ForegroundColor Red
exit 1
}
}
Write-Host "[1/3] Ejecutable encontrado: dist\JNC-NetTools.exe" -ForegroundColor Green
# 2. Buscar Inno Setup (ISCC.exe)
$iscc = $null
$paths = @(
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles}\Inno Setup 6\ISCC.exe",
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe"
)
foreach ($p in $paths) {
if ($p -and (Test-Path $p)) {
$iscc = $p
break
}
}
if (-not $iscc) {
$iscc = Get-Command ISCC -ErrorAction SilentlyContinue
if ($iscc) { $iscc = $iscc.Source }
}
if (-not $iscc) {
Write-Host ""
Write-Host "ERROR: No se encontró Inno Setup (ISCC.exe)." -ForegroundColor Red
Write-Host " Instala Inno Setup 6 desde: https://jrsoftware.org/isinfo.php" -ForegroundColor Yellow
Write-Host " Luego vuelve a ejecutar este script." -ForegroundColor Yellow
exit 1
}
Write-Host "[2/3] Inno Setup: $iscc" -ForegroundColor Green
# 3. Compilar el instalador
$issPath = Join-Path $ProjectRoot "installer\installer.iss"
if (-not (Test-Path $issPath)) {
Write-Host "ERROR: No se encontró installer\installer.iss" -ForegroundColor Red
exit 1
}
Write-Host "[3/3] Compilando instalador..." -ForegroundColor Yellow
$outDir = Join-Path $ProjectRoot "dist_installer"
if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
& $iscc $issPath
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: La compilación del instalador falló." -ForegroundColor Red
exit 1
}
$setupExe = Get-ChildItem -Path $outDir -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
Write-Host ""
Write-Host "=== Listo ===" -ForegroundColor Cyan
Write-Host "Instalador: $outDir\$($setupExe.Name)" -ForegroundColor Green
Write-Host ""
Write-Host "Ejecuta ese .exe para instalar JNC-NetTools en el disco (por ejemplo en Program Files)." -ForegroundColor Gray
Write-Host ""
+130
View File
@@ -0,0 +1,130 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Genera JNC-NetTools.exe con todas las dependencias (PyInstaller).
.DESCRIPTION
Crea/usa un venv, instala requirements-win.txt y PyInstaller, y construye el .exe.
.EXAMPLE
.\Build-Windows.ps1
#>
$ErrorActionPreference = "Stop"
$ProjectRoot = $PSScriptRoot
Set-Location $ProjectRoot
Write-Host "=== JNC-NetTools - Build Windows ===" -ForegroundColor Cyan
Write-Host ""
# Detectar Python (evitar stub de Microsoft Store; priorizar "py" o python real)
$pythonExe = $null
$usePyLauncher = $false
# Probar "py" primero (launcher de Python en Windows)
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
if ($pyCmd) {
$pythonExe = "py"
$usePyLauncher = $true
}
if (-not $pythonExe) {
foreach ($name in @("python", "python3")) {
$c = Get-Command $name -ErrorAction SilentlyContinue
if ($c -and $c.Source -and $c.Source -notmatch "WindowsApps\\python") {
$pythonExe = $c.Source
break
}
}
}
# Si solo tenemos el stub de Microsoft Store, no sirve
if ($pythonExe -and $pythonExe -match "WindowsApps\\python") {
$pythonExe = $null
}
if (-not $pythonExe) {
Write-Host "ERROR: No se encontró Python instalado." -ForegroundColor Red
Write-Host " El 'python' de Microsoft Store no es suficiente. Instala Python 3.8+ desde:" -ForegroundColor Yellow
Write-Host " https://www.python.org/downloads/" -ForegroundColor White
Write-Host " Durante la instalación, marca: 'Add Python to PATH'." -ForegroundColor Yellow
exit 1
}
Write-Host "[1/5] Python: $pythonExe" -ForegroundColor Green
$venvPath = Join-Path $ProjectRoot "venv"
$venvPython = Join-Path $venvPath "Scripts\python.exe"
$venvPip = Join-Path $venvPath "Scripts\pip.exe"
$useVenv = $false
if (Test-Path $venvPython) {
Write-Host "[2/5] Usando entorno virtual existente: venv\" -ForegroundColor Green
$useVenv = $true
} else {
Write-Host "[2/5] Creando entorno virtual en venv\..." -ForegroundColor Yellow
$created = $false
if ($usePyLauncher) {
$null = & py -3 -m venv $venvPath 2>&1
if (Test-Path $venvPython) { $created = $true }
}
if (-not $created -and $pythonExe -ne "py" -and $pythonExe -notmatch "WindowsApps") {
$null = & $pythonExe -m venv $venvPath 2>&1
if (Test-Path $venvPython) { $created = $true }
}
if ($created) {
$useVenv = $true
} else {
Write-Host " Venv no disponible; usando Python del sistema." -ForegroundColor Yellow
$venvPython = $pythonExe
$venvPip = "pip"
if ($usePyLauncher) { $venvPip = "py -3 -m pip" }
}
}
# Instalar dependencias
Write-Host "[3/5] Instalando dependencias (requirements-win.txt + PyInstaller)..." -ForegroundColor Yellow
$reqPath = Join-Path $ProjectRoot "requirements-win.txt"
if ($useVenv) {
& $venvPip install --quiet --upgrade pip
& $venvPip install --quiet -r $reqPath
& $venvPip install --quiet pyinstaller
} else {
if ($usePyLauncher) {
& py -3 -m pip install --quiet --upgrade pip
& py -3 -m pip install --quiet -r $reqPath
& py -3 -m pip install --quiet pyinstaller
} else {
& $venvPython -m pip install --quiet --upgrade pip
& $venvPython -m pip install --quiet -r $reqPath
& $venvPython -m pip install --quiet pyinstaller
}
}
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: Fallo al instalar dependencias." -ForegroundColor Red
exit 1
}
Write-Host " Dependencias instaladas." -ForegroundColor Green
# Limpiar y construir
Write-Host "[4/5] Construyendo ejecutable con PyInstaller (puede tardar unos minutos)..." -ForegroundColor Yellow
if ($useVenv) {
$venvPyinstaller = Join-Path $venvPath "Scripts\pyinstaller.exe"
& $venvPyinstaller --clean --noconfirm JNC-NetTools.spec
} else {
if ($usePyLauncher) { & py -3 -m PyInstaller --clean --noconfirm JNC-NetTools.spec }
else { & $venvPython -m PyInstaller --clean --noconfirm JNC-NetTools.spec }
}
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: PyInstaller falló." -ForegroundColor Red
exit 1
}
$exePath = Join-Path $ProjectRoot "dist\JNC-NetTools.exe"
Write-Host "[5/5] Verificando salida..." -ForegroundColor Yellow
if (-not (Test-Path $exePath)) {
Write-Host "ERROR: No se generó dist\JNC-NetTools.exe" -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host "=== Listo ===" -ForegroundColor Cyan
Write-Host "Ejecutable: $exePath" -ForegroundColor Green
Write-Host ""
Write-Host "Para crear el instalador completo (Npcap + Nmap):" -ForegroundColor Gray
Write-Host " 1. Copia npcap-*.exe y nmap-*-win32.zip en installer\extras\" -ForegroundColor Gray
Write-Host " 2. Compila installer\installer.iss con Inno Setup" -ForegroundColor Gray
Write-Host ""
+60
View File
@@ -0,0 +1,60 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec para JNC-NetTools (Windows)
# Uso: pyinstaller JNC-NetTools.spec
import sys
block_cipher = None
# En Windows no usamos pyroute2 (evita errores al empaquetar)
excludes = ['pyroute2'] if sys.platform == 'win32' else []
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[
'PyQt6.QtCore',
'PyQt6.QtGui',
'PyQt6.QtWidgets',
'scapy',
'scapy.layers.l2',
'scapy.layers.inet',
'nmap',
'psutil',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=excludes,
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='JNC-NetTools',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
)
+20
View File
@@ -0,0 +1,20 @@
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "$DIR"
if [ -t 0 ]; then
echo "Iniciando JNC-Scan..."
sudo ./venv/bin/python3 main.py
else
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
x-terminal-emulator -e "sudo $DIR/venv/bin/python3 main.py"
elif command -v xterm &> /dev/null; then
xterm -e "sudo $DIR/venv/bin/python3 main.py"
else
if command -v pkexec &> /dev/null; then
pkexec $DIR/venv/bin/python3 main.py
else
notify-send "Error" "No se encontró una terminal compatible para pedir contraseña de root."
fi
fi
fi
+208
View File
@@ -0,0 +1,208 @@
# JNC-NetTools // Versión ADA - Documentación Técnica
## 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)
## 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: El launcher gestiona automáticamente la activación del entorno virtual y los privilegios de root (se solicitará la contraseña sudo).
### Windows 10 / 11
La aplicación puede ejecutarse en Windows con las siguientes funcionalidades:
- **Lista de interfaces** (psutil + netsh)
- **Detección de VLANs** en un trunk (Scapy + Npcap)
- **Exploración de red** y **Escáner de puertos** (Nmap)
- **Sniffer de tráfico** (Scapy + Npcap)
- **Diagnóstico** (ping, tracert, nslookup)
- **Monitor de red** (tráfico por interfaz)
- **Comprobación VLAN35** e **Informe HTML**
No disponibles en Windows: Configuración IP desde la app, VLAN/Puente (bridges), Routing avanzado, firewall iptables.
#### Instalación en Windows (un solo instalador)
1. **Generar el ejecutable** (en Windows, con Python instalado):
```bat
pip install -r requirements-win.txt pyinstaller
build_windows.bat
```
Se genera `dist\JNC-NetTools.exe`.
2. **Crear el instalador completo** (incluye Npcap y Nmap para no descargar nada aparte):
- Instalar [Inno Setup](https://jrsoftware.org/isinfo.php).
- Copiar en `installer\extras\`:
- `npcap-x.xx.exe` (desde [npcap.com](https://npcap.com/#download))
- `nmap-x.xx-win32.zip` (desde [nmap.org](https://nmap.org/download.html))
- Compilar `installer\installer.iss` con Inno Setup.
- El instalador resultante instalará la app, Npcap y Nmap automáticamente.
3. **Requisitos**: Ejecutar como **Administrador** para Sniffer y Detección VLAN (Npcap).
## 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
```
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 # 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
│ │ ├── port_scanner.py
│ │ ├── sniffer_view.py
│ │ └── diagnostics_view.py
│ └── utils/
│ └── 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
### 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"
### 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
### 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")`
### 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`) |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
This file lists modules PyInstaller was not able to find. This does not
necessarily mean these modules are required for running your program. Both
Python's standard library and 3rd-party Python packages often conditionally
import optional modules, some of which may be available only on certain
platforms.
Types of import:
* top-level: imported at the top-level - look at these first
* conditional: imported within an if-statement
* delayed: imported within a function
* optional: imported within a try-except-statement
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named 'collections.abc' - imported by typing (top-level), tracemalloc (top-level), traceback (top-level), _colorize (top-level), selectors (top-level), logging (top-level), http.client (top-level), importlib.resources.readers (top-level), inspect (top-level), multiprocessing.managers (top-level), xml.etree.ElementTree (top-level), asyncio.base_events (top-level), asyncio.coroutines (top-level), scapy.layers.dhcp (optional)
missing module named pwd - imported by posixpath (delayed, conditional, optional), subprocess (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (optional), netrc (delayed, optional), psutil (optional)
missing module named grp - imported by subprocess (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (optional)
missing module named _scproxy - imported by urllib.request (conditional)
missing module named fcntl - imported by subprocess (optional), pathlib._os (optional), src.core.network_manager (conditional), scapy.arch.linux (top-level), scapy.arch.bpf.core (top-level), scapy.arch.bpf.supersocket (top-level), scapy.arch.libpcap (conditional), scapy.arch.unix (top-level), scapy.utils (delayed, optional), scapy.layers.tuntap (top-level)
missing module named posix - imported by os (conditional, optional), posixpath (optional), shutil (conditional), importlib._bootstrap_external (conditional), pathlib._os (optional)
missing module named resource - imported by posix (top-level)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
missing module named pyimod02_importers - imported by D:\Joaquin\Programas\JNC Scan Windows\venv\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed)
missing module named _posixsubprocess - imported by subprocess (conditional), multiprocessing.util (delayed)
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.forkserver (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.Process - imported by multiprocessing (top-level), nmap.nmap (top-level)
missing module named IPython - imported by scapy.arch.windows (delayed, optional), scapy.main (delayed, conditional, optional)
missing module named vms_lib - imported by platform (delayed, optional)
missing module named 'java.lang' - imported by platform (delayed, optional)
missing module named java - imported by platform (delayed)
missing module named matplotlib - imported by scapy.libs.matplot (optional), scapy.layers.inet (delayed)
missing module named 'matplotlib.collections' - imported by scapy.layers.inet (delayed)
missing module named cartopy - imported by scapy.layers.inet (delayed, optional)
missing module named 'geoip2.errors' - imported by scapy.layers.inet (delayed, optional)
missing module named geoip2 - imported by scapy.layers.inet (delayed, optional)
missing module named vpython - imported by scapy.layers.inet (delayed)
missing module named 'matplotlib.lines' - imported by scapy.libs.matplot (optional)
missing module named colorama - imported by scapy.error (conditional, optional)
missing module named pyx - imported by scapy.base_classes (conditional, optional), scapy.plist (optional), scapy.libs.test_pyx (optional), scapy.packet (optional)
missing module named 'bpython.curtsies' - imported by scapy.main (delayed, conditional)
missing module named 'ptpython.repl' - imported by scapy.main (delayed, conditional)
missing module named traitlets - imported by scapy.main (delayed, conditional, optional)
missing module named ptpython - imported by scapy.main (delayed, conditional)
missing module named bpython - imported by scapy.main (delayed, conditional)
missing module named readline - imported by code (delayed, conditional, optional), rlcompleter (optional), scapy.main (delayed, conditional, optional)
missing module named 'prompt_toolkit.formatted_text' - imported by scapy.packet (delayed, conditional)
missing module named 'prompt_toolkit.shortcuts' - imported by scapy.packet (delayed, conditional)
missing module named prompt_toolkit - imported by scapy.utils (delayed, conditional, optional), scapy.packet (delayed, conditional, optional), scapy.layers.kerberos (delayed, conditional, optional), scapy.layers.spnego (delayed, conditional), scapy.modules.ticketer (delayed, conditional, optional)
missing module named 'IPython.terminal' - imported by scapy.themes (delayed, optional)
missing module named __pypy__ - imported by scapy.config (delayed, optional)
missing module named 'cryptography.hazmat' - imported by scapy.layers.tls.crypto.pkcs1 (conditional), scapy.layers.tls.crypto.hkdf (conditional), scapy.layers.tls.cert (conditional, optional), scapy.layers.tls.crypto.groups (conditional), scapy.layers.tls.keyexchange (conditional), scapy.layers.tls.crypto.cipher_aead (conditional), scapy.layers.tls.crypto.cipher_block (conditional, optional), scapy.layers.tls.crypto.cipher_stream (conditional, optional), scapy.layers.tls.keyexchange_tls13 (conditional), scapy.layers.tls.handshake (conditional), scapy.config (delayed, optional), scapy.libs.rfc3961 (optional), scapy.layers.smb2 (delayed, conditional), scapy.layers.kerberos (conditional), scapy.layers.gssapi (delayed), scapy.layers.ntlm (delayed, optional), scapy.layers.msrpce.msnrpc (conditional, optional), scapy.layers.dot11 (conditional, optional), scapy.layers.ipsec (conditional, optional), scapy.layers.tls.automaton_srv (conditional)
missing module named cryptography - imported by scapy.layers.tls.crypto.pkcs1 (conditional), scapy.config (delayed, optional)
missing module named __builtin__ - imported by scapy.config (delayed, optional)
missing module named 'prompt_toolkit.completion' - imported by scapy.utils (delayed)
missing module named termios - imported by scapy.utils (delayed, optional)
missing module named 'cryptography.exceptions' - imported by scapy.layers.tls.cert (conditional), scapy.layers.tls.crypto.cipher_aead (conditional), scapy.layers.ipsec (conditional)
missing module named 'cryptography.utils' - imported by scapy.layers.tls.crypto.cipher_block (conditional), scapy.layers.ipsec (conditional)
missing module named collections.Iterable - imported by collections (optional), scapy.layers.dhcp (optional)
excluded module named pyroute2 - imported by src.core.network_manager (conditional), src.core.advanced_router (top-level)
missing module named zstandard - imported by scapy.layers.http (optional)
missing module named lzw - imported by scapy.layers.http (optional)
missing module named brotli - imported by scapy.layers.http (optional)
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
@echo off
REM Build JNC-NetTools.exe con PyInstaller
REM Requiere: pip install pyinstaller y dependencias (requirements-win.txt)
cd /d "%~dp0"
echo Instalando dependencias Windows...
pip install -r requirements-win.txt -q
pip install pyinstaller -q
echo Construyendo ejecutable...
pyinstaller --clean JNC-NetTools.spec
if %ERRORLEVEL% neq 0 (
echo Error en la construccion.
exit /b 1
)
echo.
echo Listo. Ejecutable en: dist\JNC-NetTools.exe
echo Para crear el instalador completo (Npcap + Nmap), ejecuta el script de Inno Setup (installer\installer.iss) despues de copiar Npcap y Nmap en installer\extras\
exit /b 0
BIN
View File
Binary file not shown.
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
Coloca aquí los archivos para el instalador:
1. npcap-x.xx.exe - Descargar de https://npcap.com/#download
(necesario para Sniffer y Detección VLAN)
2. nmap-x.xx-win32.zip - Descargar de https://nmap.org/download.html (Windows binaries)
(necesario para Exploración de red y Escáner de puertos)
Luego compila el instalador con Inno Setup (installer.iss).
+44
View File
@@ -0,0 +1,44 @@
; Inno Setup - JNC-NetTools (instala la aplicación en el disco)
; Requiere: dist\JNC-NetTools.exe (ejecutar antes Build-Windows.ps1)
; Compilar: Build-Setup.ps1 o abrir este archivo con Inno Setup
#define MyAppName "JNC-NetTools"
#define MyAppVersion "1.0"
#define MyAppPublisher "ADA - Agencia Digital de Andalucía"
#define MyAppExeName "JNC-NetTools.exe"
[Setup]
AppId={{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
AllowNoIcons=yes
OutputDir=..\dist_installer
OutputBaseFilename=JNC-NetTools-Setup-{#MyAppVersion}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
UninstallDisplayIcon={app}\{#MyAppExeName}
[Languages]
Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "..\dist\JNC-NetTools.exe"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\Desinstalar {#MyAppName}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "Iniciar {#MyAppName}"; Flags: nowait postinstall skipifsilent
+26
View File
@@ -0,0 +1,26 @@
import sys
import os
from PyQt6.QtWidgets import QApplication
from src.gui.main_window import MainWindow
from src.gui.styles import apply_styles
from src.utils.platform import is_windows
def main():
# En Linux se requieren privilegios root para interfaces, sniffing, etc.
if not is_windows() and os.geteuid() != 0:
print("This application requires root privileges to manage network interfaces.")
print("Please run with sudo.")
sys.exit(1)
app = QApplication(sys.argv)
# Apply premium styles
apply_styles(app)
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
# Dependencias para Windows (sin pyroute2)
PyQt6
scapy
python-nmap
psutil
+6
View File
@@ -0,0 +1,6 @@
PyQt6
pyroute2
scapy
python-nmap
netifaces
psutil
View File
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.
+996
View File
@@ -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)
+64
View File
@@ -0,0 +1,64 @@
import subprocess
import platform
def _is_windows():
return platform.system().lower() == 'windows'
class DiagnosticsManager:
def __init__(self):
pass
def run_ping(self, host, count=4):
"""
Executes the ping command.
Returns a generator that yields output lines.
"""
param = '-n' if _is_windows() else '-c'
command = ['ping', param, str(count), host]
return self._run_command(command)
def run_traceroute(self, host):
"""
Executes traceroute. On Windows uses tracert, on Linux tracepath.
Returns a generator that yields output lines.
"""
if _is_windows():
command = ['tracert', '-d', host]
else:
command = ['tracepath', host]
return self._run_command(command)
def run_dns_lookup(self, host, query_type='A'):
"""
DNS lookup. On Windows uses nslookup, on Linux dig.
Returns a generator that yields output lines.
"""
if _is_windows():
command = ['nslookup', '-type=' + query_type, host]
else:
command = ['dig', host, query_type]
return self._run_command(command)
def _run_command(self, command):
"""
Helper to run a command and yield its output line by line.
"""
try:
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True
)
for line in process.stdout:
yield line.strip()
process.stdout.close()
process.wait()
except Exception as e:
yield f"Error executing command: {str(e)}"
+264
View File
@@ -0,0 +1,264 @@
"""
NetworkManager: implementación Linux (pyroute2, nmcli) o Windows (psutil, netsh).
"""
from src.utils.platform import is_windows
if is_windows():
from src.core.network_manager_win import NetworkManager
else:
import socket
import fcntl
import struct
import psutil
import subprocess
import time
from typing import Tuple
from pyroute2 import IPRoute
class NetworkManager:
def __init__(self):
self.ipr = IPRoute()
def list_interfaces(self):
interfaces = []
stats = psutil.net_if_stats()
addrs = psutil.net_if_addrs()
links = self.ipr.get_links()
for link in links:
if_name = link.get_attr('IFLA_IFNAME')
if if_name == 'lo':
continue
if_index = link['index']
mac_address = link.get_attr('IFLA_ADDRESS')
operstate = link.get_attr('IFLA_OPERSTATE')
ip_addrs = self.ipr.get_addr(index=if_index)
ipv4 = []
ipv6 = []
for addr in ip_addrs:
family = addr['family']
ip = addr.get_attr('IFA_ADDRESS')
prefixlen = addr['prefixlen']
if family == socket.AF_INET:
ipv4.append(f"{ip}/{prefixlen}")
elif family == socket.AF_INET6:
ipv6.append(f"{ip}/{prefixlen}")
is_wireless = False
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
fcntl.ioctl(s.fileno(), 0x8B01, struct.pack('256s', if_name.encode()[:15]))
is_wireless = True
except OSError:
pass
interfaces.append({
'name': if_name,
'mac': mac_address,
'status': operstate,
'ipv4': ipv4,
'ipv6': ipv6,
'type': 'Wireless' if is_wireless else 'Wired',
'is_up': operstate == 'UP',
'config': self.get_interface_config(if_name),
})
return interfaces
def get_interface_config(self, iface_name):
config = {'method': 'unknown', 'ip': '', 'prefix': '', 'gateway': '', 'dns': ''}
try:
cmd_conn = ["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", iface_name]
res_conn = subprocess.run(cmd_conn, capture_output=True, text=True)
conn_name = res_conn.stdout.strip().split(':')[1] if ':' in res_conn.stdout else res_conn.stdout.strip()
if conn_name:
cmd_method = ["nmcli", "-t", "-f", "ipv4.method", "connection", "show", conn_name]
res_method = subprocess.run(cmd_method, capture_output=True, text=True)
config['method'] = res_method.stdout.strip().split(':')[1] if ':' in res_method.stdout else res_method.stdout.strip()
cmd_dev = ["nmcli", "-t", "-f", "IP4.ADDRESS,IP4.GATEWAY,IP4.DNS", "device", "show", iface_name]
result = subprocess.run(cmd_dev, capture_output=True, text=True)
for line in result.stdout.strip().split('\n'):
if ':' not in line:
continue
key, value = line.split(':', 1)
value = value.strip()
if key.startswith("IP4.ADDRESS"):
if '/' in value:
config['ip'], config['prefix'] = value.split('/')
else:
config['ip'] = value
elif key == "IP4.GATEWAY":
config['gateway'] = value
elif key.startswith("IP4.DNS"):
config['dns'] = config['dns'] + ", " + value if config['dns'] else value
except Exception:
pass
return config
def apply_interface_config(self, iface_name, method, ip=None, prefix=None, gateway=None, dns=None):
try:
cmd = ["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", iface_name]
result = subprocess.run(cmd, capture_output=True, text=True)
conn_name = result.stdout.strip().split(':')[1] if ':' in result.stdout else result.stdout.strip()
if not conn_name:
return False, "No NetworkManager connection found for this interface."
cmds = ["nmcli", "connection", "modify", conn_name, "ipv4.method", method]
if method == 'manual':
if not ip or not prefix:
return False, "IP and Prefix required for manual mode."
cmds.extend(["ipv4.addresses", f"{ip}/{prefix}"])
if gateway:
cmds.extend(["ipv4.gateway", gateway])
if dns:
cmds.extend(["ipv4.dns", dns])
else:
cmds.extend(["ipv4.addresses", "", "ipv4.gateway", "", "ipv4.dns", ""])
subprocess.run(cmds, check=True)
subprocess.run(["nmcli", "connection", "up", conn_name], check=True)
return True, f"Configuration applied to {iface_name}"
except subprocess.CalledProcessError as e:
return False, f"Failed to apply config: {str(e)}"
except Exception as e:
return False, str(e)
def set_ip_config(self, iface_name, ip_address, prefix_len, gateway=None):
return self.apply_interface_config(iface_name, 'manual', ip_address, prefix_len, gateway)
def set_dhcp(self, iface_name):
return self.apply_interface_config(iface_name, 'auto')
def create_vlan_bridge(self, trunk_iface, vlan_id, access_iface):
vlan_iface_name = f"{trunk_iface}.{vlan_id}"
bridge_name = f"br{vlan_id}"
try:
trunk_idx = self.ipr.link_lookup(ifname=trunk_iface)[0]
self.ipr.link('add', ifname=vlan_iface_name, kind='vlan', link=trunk_idx, vlan_id=int(vlan_id))
self.ipr.link('add', ifname=bridge_name, kind='bridge')
vlan_idx = self.ipr.link_lookup(ifname=vlan_iface_name)[0]
access_idx = self.ipr.link_lookup(ifname=access_iface)[0]
bridge_idx = self.ipr.link_lookup(ifname=bridge_name)[0]
self.ipr.link('set', index=vlan_idx, master=bridge_idx)
self.ipr.link('set', index=access_idx, master=bridge_idx)
self.ipr.link('set', index=vlan_idx, state='up')
self.ipr.link('set', index=access_idx, state='up')
self.ipr.link('set', index=bridge_idx, state='up')
return True, f"Bridge {bridge_name} created successfully."
except Exception as e:
return False, str(e)
def create_simple_bridge(self, iface1, iface2):
bridge_name = "br_pass"
try:
if self.ipr.link_lookup(ifname=bridge_name):
return False, f"Bridge {bridge_name} already exists."
self.ipr.link('add', ifname=bridge_name, kind='bridge')
br_idx = self.ipr.link_lookup(ifname=bridge_name)[0]
idx1 = self.ipr.link_lookup(ifname=iface1)[0]
idx2 = self.ipr.link_lookup(ifname=iface2)[0]
def prepare_interface(iface_name):
is_wireless = False
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
fcntl.ioctl(s.fileno(), 0x8B01, struct.pack('256s', iface_name.encode()[:15]))
is_wireless = True
except OSError:
pass
if is_wireless:
try:
subprocess.run(["iw", "dev", iface_name, "set", "4addr", "on"], check=True, capture_output=True)
except (subprocess.CalledProcessError, FileNotFoundError):
pass
prepare_interface(iface1)
prepare_interface(iface2)
try:
self.ipr.link('set', index=idx1, master=br_idx)
self.ipr.link('set', index=idx2, master=br_idx)
except Exception as e:
if "95" in str(e) or "Operation not supported" in str(e):
self.ipr.link('del', index=br_idx)
return False, "Bridging failed (Error 95). Wi-Fi may not support 4addr (WDS)."
raise e
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} created between {iface1} and {iface2}"
except Exception as e:
try:
br_idx = self.ipr.link_lookup(ifname=bridge_name)
if br_idx:
self.ipr.link('del', index=br_idx[0])
except Exception:
pass
return False, str(e)
def start_dhcp_server(self, interface, ip, range_start, range_end):
try:
self.apply_interface_config(interface, 'manual', ip, '24')
subprocess.run(["pkill", "-f", f"dnsmasq.*{interface}"], check=False)
cmd = [
"dnsmasq", "--no-daemon",
f"--interface={interface}",
f"--dhcp-range={range_start},{range_end},12h",
f"--listen-address={ip}",
"--bind-interfaces"
]
subprocess.Popen(cmd)
return True, f"DHCP Server started on {interface}"
except Exception as e:
return False, str(e)
def delete_bridge(self, bridge_name):
try:
subprocess.run(["pkill", "-f", f"dnsmasq.*{bridge_name}"], check=False)
idx = self.ipr.link_lookup(ifname=bridge_name)
if not idx:
return False, "Bridge not found"
self.ipr.link('del', index=idx[0])
return True, "Bridge deleted"
except Exception as e:
return False, str(e)
def setup_wifi_ap(self, wifi_iface, ssid, password, bridge_name):
import os
hostapd_conf = f"""
interface={wifi_iface}
bridge={bridge_name}
driver=nl80211
ssid={ssid}
hw_mode=g
channel=7
wmm_enabled=0
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0
wpa=2
wpa_passphrase={password}
wpa_key_mgmt=WPA-PSK
wpa_pairwise=TKIP
rsn_pairwise=CCMP
"""
conf_path = f"/tmp/hostapd_{wifi_iface}.conf"
with open(conf_path, "w") as f:
f.write(hostapd_conf)
subprocess.run(["pkill", "-f", f"hostapd_{wifi_iface}.conf"], check=False)
try:
subprocess.Popen(["hostapd", conf_path])
return True, "Wi-Fi AP started."
except Exception as e:
return False, str(e)
def reset_interface(self, iface_name: str) -> Tuple[bool, str]:
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]
self.ipr.link("set", index=idx, state="down")
time.sleep(0.5)
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()
+173
View File
@@ -0,0 +1,173 @@
"""
Implementación de NetworkManager para Windows.
Usa psutil + netsh; no requiere pyroute2 ni nmcli.
"""
import socket
import subprocess
import re
from typing import Tuple, List, Dict, Any
import psutil
def _parse_netsh_config() -> Dict[str, Dict[str, str]]:
"""Ejecuta netsh interface ipv4 show config y devuelve dict por interfaz."""
out: Dict[str, Dict[str, str]] = {}
try:
r = subprocess.run(
["netsh", "interface", "ipv4", "show", "config"],
capture_output=True,
text=True,
timeout=15,
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
)
if r.returncode != 0:
return out
current = None
for line in r.stdout.splitlines():
# "Configuration for interface "Ethernet""
m = re.match(r'Configuration for interface "([^"]+)"', line)
if m:
current = m.group(1)
out[current] = {"method": "unknown", "ip": "", "prefix": "", "gateway": "", "dns": ""}
continue
if current is None:
continue
line = line.strip()
if line.startswith("DHCP enabled:"):
val = line.split(":", 1)[1].strip().lower()
out[current]["method"] = "auto" if val == "yes" else "manual"
elif line.startswith("IP Address:"):
out[current]["ip"] = line.split(":", 1)[1].strip()
elif line.startswith("Subnet Prefix:"):
val = line.split(":", 1)[1].strip()
if "/" in val:
ip_part, prefix_part = val.split("/", 1)
out[current]["ip"] = out[current]["ip"] or ip_part.strip()
out[current]["prefix"] = prefix_part.strip()
elif line.startswith("Default Gateway:"):
out[current]["gateway"] = line.split(":", 1)[1].strip()
elif line.startswith("DNS Servers:"):
out[current]["dns"] = line.split(":", 1)[1].strip()
except Exception:
pass
return out
def _is_mac_address(addr: str) -> bool:
if not addr or len(addr) != 17:
return False
parts = addr.split(":")
return len(parts) == 6 and all(len(p) == 2 and p.isalnum() for p in parts)
class NetworkManager:
"""NetworkManager para Windows: list_interfaces y get_interface_config con psutil + netsh."""
def __init__(self):
pass
def list_interfaces(self) -> List[Dict[str, Any]]:
interfaces = []
stats = psutil.net_if_stats()
addrs = psutil.net_if_addrs()
netsh_config = _parse_netsh_config()
for if_name, if_addrs in addrs.items():
if if_name == "lo" or "Loopback" in if_name:
continue
is_up = False
if if_name in stats:
is_up = stats[if_name].isup
ipv4 = []
ipv6 = []
mac = ""
for addr in if_addrs:
try:
af = addr.family
addr_str = addr.address
except AttributeError:
af = addr[0]
addr_str = addr[1]
if af == socket.AF_INET:
ipv4.append(addr_str)
elif af == socket.AF_INET6 and not addr_str.startswith("fe80") and addr_str != "::1":
ipv6.append(addr_str)
elif _is_mac_address(addr_str):
mac = addr_str
if not mac:
for addr in if_addrs:
try:
a = getattr(addr, "address", addr[1])
except (IndexError, TypeError):
continue
if _is_mac_address(a):
mac = a
break
config = netsh_config.get(if_name, {
"method": "unknown", "ip": "", "prefix": "", "gateway": "", "dns": ""
})
if ipv4 and not config.get("ip"):
config = dict(config)
config["ip"] = ipv4[0].split("/")[0] if "/" in str(ipv4[0]) else str(ipv4[0])
# Formato ip/prefix como en Linux cuando hay prefix en netsh
if config.get("prefix") and config.get("ip"):
ipv4_formatted = [f"{config['ip']}/{config['prefix']}"]
elif ipv4:
ipv4_formatted = [a if "/" in str(a) else str(a) for a in ipv4]
else:
ipv4_formatted = []
interfaces.append({
"name": if_name,
"mac": mac or "N/A",
"status": "UP" if is_up else "DOWN",
"ipv4": ipv4_formatted if ipv4 else [],
"ipv6": ipv6,
"type": "Wired",
"is_up": is_up,
"config": config,
})
return interfaces
def get_interface_config(self, iface_name: str) -> Dict[str, str]:
configs = _parse_netsh_config()
return configs.get(iface_name, {
"method": "unknown", "ip": "", "prefix": "", "gateway": "", "dns": ""
})
def apply_interface_config(
self, iface_name: str, method: str,
ip: str = None, prefix: str = None, gateway: str = None, dns: str = None
) -> Tuple[bool, str]:
return False, "Configuración IP no disponible en Windows desde esta aplicación."
def set_ip_config(self, iface_name: str, ip_address: str, prefix_len: str, gateway: str = None) -> Tuple[bool, str]:
return False, "No disponible en Windows."
def set_dhcp(self, iface_name: str) -> Tuple[bool, str]:
return False, "No disponible en Windows."
def create_vlan_bridge(self, trunk_iface: str, vlan_id: int, access_iface: str) -> Tuple[bool, str]:
return False, "VLAN/Bridge no disponible en Windows."
def create_simple_bridge(self, iface1: str, iface2: str) -> Tuple[bool, str]:
return False, "Bridge no disponible en Windows."
def start_dhcp_server(
self, interface: str, ip: str, range_start: str, range_end: str
) -> Tuple[bool, str]:
return False, "DHCP no disponible en Windows."
def delete_bridge(self, bridge_name: str) -> Tuple[bool, str]:
return False, "No disponible en Windows."
def setup_wifi_ap(
self, wifi_iface: str, ssid: str, password: str, bridge_name: str
) -> Tuple[bool, str]:
return False, "Wi-Fi AP no disponible en Windows."
def reset_interface(self, iface_name: str) -> Tuple[bool, str]:
return False, "Reinicio de interfaz no disponible en Windows."
def close(self) -> None:
pass
+138
View File
@@ -0,0 +1,138 @@
import nmap
import subprocess
import shlex
import tempfile
import os
import re
from PyQt6.QtCore import QThread, pyqtSignal
from src.utils.nmap_path import get_nmap_command
class ScannerThread(QThread):
scan_finished = pyqtSignal(dict)
scan_error = pyqtSignal(str)
scan_progress = pyqtSignal(str)
port_found = pyqtSignal(int, str, str, str) # port, proto, state, service
def __init__(self, target, arguments):
super().__init__()
self.target = target
self.arguments = arguments
self.process = None
self.temp_xml = None
def run(self):
try:
# Create temp file for XML output
fd, self.temp_xml = tempfile.mkstemp(suffix='.xml')
os.close(fd)
# Construct command: nmap -oX <temp_file> -v <arguments> <target>
nmap_cmd = get_nmap_command()
cmd = nmap_cmd + ["-oX", self.temp_xml, "-v"] + shlex.split(self.arguments) + [self.target]
self.scan_progress.emit(f"Executing: {' '.join(cmd)}\n")
creationflags = 0
if hasattr(subprocess, "CREATE_NO_WINDOW"):
creationflags = subprocess.CREATE_NO_WINDOW
self.process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
creationflags=creationflags,
)
# Regex for discovered ports: "Discovered open port 80/tcp on 192.168.1.1"
port_regex = re.compile(r"Discovered open port (\d+)/(\w+) on")
# Read stdout line by line for progress
while True:
line = self.process.stdout.readline()
if not line and self.process.poll() is not None:
break
if line:
self.scan_progress.emit(line.strip())
# Check for discovered port
match = port_regex.search(line)
if match:
port = int(match.group(1))
proto = match.group(2).upper()
self.port_found.emit(port, proto, "open", "")
stdout, stderr = self.process.communicate()
if self.process.returncode == 0:
# Parse XML output from file
nm = nmap.PortScanner()
try:
with open(self.temp_xml, 'r') as f:
xml_content = f.read()
if not xml_content:
self.scan_error.emit("Scan produced no output.")
return
result = nm.analyse_nmap_xml_scan(xml_content)
if result is None:
result = nm.scan_result
self.scan_finished.emit(result)
except Exception as e:
self.scan_error.emit(f"Error parsing scan results: {str(e)}")
else:
# If killed or failed
if self.process.returncode == -9: # SIGKILL
self.scan_progress.emit("\nScan stopped by user.")
self.scan_error.emit("Scan stopped by user.")
else:
self.scan_error.emit(f"Scan failed: {stderr}")
except Exception as e:
self.scan_error.emit(str(e))
finally:
# Cleanup temp file
if self.temp_xml and os.path.exists(self.temp_xml):
try:
os.remove(self.temp_xml)
except:
pass
def stop(self):
if self.process:
self.process.kill()
self.wait()
class NetworkScanner:
def __init__(self):
pass
def scan(self, target, ports=None, scan_type="TCP", skip_discovery=False):
"""
Initiates a scan. Returns the thread object.
target: IP or range (e.g., '192.168.1.1' or '192.168.1.0/24')
ports: String of ports (e.g., '80,443' or '1-1000')
scan_type: 'TCP' or 'UDP'
skip_discovery: If True, adds -Pn (treat all as online). If False, performs host discovery (ARP/Ping).
"""
# -T4: Aggressive timing
# --stats-every 5s: Print progress line every 5 seconds
# --min-rate 1000: Send packets at least 1000 per second (Speed up)
arguments = "-T4 --stats-every 5s --min-rate 1000"
if skip_discovery:
arguments += " -Pn"
if scan_type == "UDP":
arguments += " -sU"
else:
arguments += " -sT" # Connect scan (safer for non-root)
if ports:
arguments += f" -p {ports}"
else:
arguments += " -F" # Fast scan if no ports specified
thread = ScannerThread(target, arguments)
thread.start()
return thread
+138
View File
@@ -0,0 +1,138 @@
from scapy.all import sniff, IP, TCP, UDP, ARP, ICMP
from PyQt6.QtCore import QThread, pyqtSignal
import datetime
class SnifferThread(QThread):
packet_received = pyqtSignal(dict)
error_occurred = pyqtSignal(str)
def __init__(self, interface, filter_str=""):
super().__init__()
self.interface = interface
self.filter_str = filter_str
self.running = True
def run(self):
try:
# Check if interface is up
if not self.is_interface_up(self.interface):
raise Exception(f"Interface {self.interface} is DOWN. Please enable it first.")
sniff(iface=self.interface,
prn=self.process_packet,
filter=self.filter_str,
store=0,
stop_filter=lambda x: not self.running)
except Exception as e:
self.error_occurred.emit(str(e))
def is_interface_up(self, interface):
try:
import psutil
stats = psutil.net_if_stats()
if interface in stats:
return stats[interface].isup
return False
except:
return True # Assume up if check fails to avoid blocking
def process_packet(self, packet):
if not self.running:
return
try:
pkt_info = {
'time': datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3],
'src': 'Unknown',
'dst': 'Unknown',
'proto': 'Unknown',
'info': packet.summary()
}
if IP in packet:
pkt_info['src'] = packet[IP].src
pkt_info['dst'] = packet[IP].dst
pkt_info['proto'] = packet[IP].proto
# Printer Ports
printer_ports = [631, 515, 9100]
if TCP in packet:
pkt_info['proto'] = 'TCP'
flags = packet[TCP].flags
flag_str = str(flags)
sport = packet[TCP].sport
dport = packet[TCP].dport
info = f"{sport} -> {dport} [{flag_str}]"
# Check for Printer Traffic
if sport in printer_ports or dport in printer_ports:
info = f"[PRINTER] {info}"
# Check for potential issues (simple heuristics)
if 'R' in flag_str: # RST
info += " [RESET]"
pkt_info['status'] = 'Error'
elif 'S' in flag_str and 'A' not in flag_str: # SYN only
info += " [SYN]"
pkt_info['status'] = 'Connecting'
else:
pkt_info['status'] = 'OK'
pkt_info['info'] = info
elif UDP in packet:
pkt_info['proto'] = 'UDP'
sport = packet[UDP].sport
dport = packet[UDP].dport
info = f"{sport} -> {dport}"
if sport in printer_ports or dport in printer_ports:
info = f"[PRINTER] {info}"
pkt_info['info'] = info
pkt_info['status'] = 'OK'
elif ICMP in packet:
pkt_info['proto'] = 'ICMP'
icmp_type = packet[ICMP].type
icmp_code = packet[ICMP].code
if icmp_type == 3: # Destination Unreachable
pkt_info['info'] = f"Dest Unreachable (Code: {icmp_code})"
pkt_info['status'] = 'Error'
else:
pkt_info['info'] = f"Type: {icmp_type} Code: {icmp_code}"
pkt_info['status'] = 'OK'
elif ARP in packet:
pkt_info['proto'] = 'ARP'
pkt_info['src'] = packet[ARP].psrc
pkt_info['dst'] = packet[ARP].pdst
pkt_info['info'] = f"Who has {packet[ARP].pdst}? Tell {packet[ARP].psrc}"
pkt_info['status'] = 'OK'
self.packet_received.emit(pkt_info)
except Exception:
pass
def stop(self):
self.running = False
class NetworkSniffer:
def __init__(self):
self.thread = None
def start_sniffing(self, interface, filter_str=""):
self.thread = SnifferThread(interface, filter_str)
self.thread.start()
return self.thread
def stop_sniffing(self):
if self.thread:
self.thread.stop()
self.thread.wait()
self.thread = None
+306
View File
@@ -0,0 +1,306 @@
import socket
import subprocess
import time
import os
import json
import copy
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):
# Configuracion por defecto para pruebas de VLAN 35
self.default_tests = [
{"host": "10.47.28.7", "port": 515, "protocol": "TCP", "type": "connectivity"},
{"host": "10.47.28.7", "port": 9195, "protocol": "TCP", "type": "connectivity"},
{"host": "10.47.28.7", "port": 9191, "protocol": "TCP", "type": "connectivity"},
{"host": "10.47.28.7", "port": 9192, "protocol": "TCP", "type": "connectivity"},
{"host": "10.47.28.7", "port": 465, "protocol": "TCP", "type": "connectivity"},
{"host": "10.47.28.7", "port": 123, "protocol": "UDP", "type": "ntp"},
{"host": "10.47.28.7", "port": 6564, "protocol": "TCP", "type": "connectivity"},
]
self.config_file = self._get_config_file()
def get_default_tests(self) -> List[Dict]:
"""Retorna la lista de tests por defecto."""
return copy.deepcopy(self.default_tests)
def load_tests(self) -> List[Dict]:
"""Carga tests guardados desde disco; si no hay, usa defaults."""
if not os.path.exists(self.config_file):
return self.get_default_tests()
try:
with open(self.config_file, "r", encoding="utf-8") as f:
payload = json.load(f)
tests = payload.get("tests", [])
normalized_tests = []
for test in tests:
normalized = self._normalize_test(test)
if normalized:
normalized_tests.append(normalized)
if normalized_tests:
return normalized_tests
except Exception:
pass
return self.get_default_tests()
def save_tests(self, tests: List[Dict]) -> Tuple[bool, str]:
"""Guarda tests en disco para persistir entre reinicios."""
normalized_tests = []
for test in tests:
normalized = self._normalize_test(test)
if normalized:
normalized_tests.append(normalized)
if not normalized_tests:
return False, "No hay tests validos para guardar."
try:
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
payload = {
"tests": normalized_tests,
"saved_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
return True, "Configuracion de pruebas guardada."
except Exception as e:
return False, f"No se pudo guardar la configuracion: {e}"
def run_tests(self, tests: List[Dict]) -> ConnectivityTestThread:
"""Inicia los tests en un thread."""
thread = ConnectivityTestThread(tests)
thread.start()
return thread
def _get_config_file(self) -> str:
"""Retorna ruta para persistencia en Windows/Linux."""
base_dir = os.getenv("APPDATA")
if not base_dir:
base_dir = os.path.expanduser("~")
return os.path.join(base_dir, "JNC-NetTools", "vlan35_tests.json")
def _normalize_test(self, test: Dict) -> Optional[Dict]:
"""Valida y normaliza una entrada de test."""
try:
host = str(test.get("host", "")).strip()
port = int(test.get("port", 0))
protocol = str(test.get("protocol", "TCP")).upper()
test_type = str(test.get("type", "connectivity")).lower()
if not host or port < 1 or port > 65535:
return None
if protocol not in {"TCP", "UDP"}:
return None
if test_type not in {"connectivity", "ntp"}:
test_type = "connectivity"
return {
"host": host,
"port": port,
"protocol": protocol,
"type": test_type,
}
except (TypeError, ValueError):
return None
+166
View File
@@ -0,0 +1,166 @@
from PyQt6.QtCore import QThread, pyqtSignal
import subprocess
import re
import time
def _scapy_available():
try:
from scapy.all import sniff # noqa: F401
return True
except Exception:
return False
class VLANScannerThreadScapy(QThread):
"""Escaneo de VLANs con Scapy (funciona en Windows con Npcap y en Linux)."""
vlan_detected = pyqtSignal(int, int)
untagged_detected = pyqtSignal(int)
scan_finished = pyqtSignal()
error_occurred = pyqtSignal(str)
def __init__(self, interface, timeout=10):
super().__init__()
self.interface = interface
self.timeout = timeout
self.running = True
self.detected_vlans = {}
self.untagged_count = 0
def run(self):
try:
from scapy.all import sniff
from scapy.layers.l2 import Dot1Q, Ether
def _process(pkt):
if not self.running:
return
try:
if Dot1Q in pkt:
vid = pkt[Dot1Q].vlan
if 1 <= vid <= 4094:
self.detected_vlans[vid] = self.detected_vlans.get(vid, 0) + 1
self.vlan_detected.emit(vid, self.detected_vlans[vid])
elif Ether in pkt:
self.untagged_count += 1
self.untagged_detected.emit(self.untagged_count)
except Exception:
pass
sniff(
iface=self.interface,
prn=_process,
store=False,
stop_filter=lambda _: not self.running,
timeout=self.timeout,
)
except Exception as e:
self.error_occurred.emit(str(e))
finally:
self.scan_finished.emit()
def stop(self):
self.running = False
self.wait(2000)
class VLANScannerThread(QThread):
vlan_detected = pyqtSignal(int, int) # vlan_id, count
untagged_detected = pyqtSignal(int) # count
scan_finished = pyqtSignal()
error_occurred = pyqtSignal(str)
def __init__(self, interface, timeout=10):
super().__init__()
self.interface = interface
self.timeout = timeout
self.running = True
self.process = None
self.detected_vlans = {} # vlan_id -> count
self.untagged_count = 0
def run(self):
try:
# Use tcpdump to capture ALL packets to detect untagged vs tagged
# -i: interface
# -e: print link-level header (to see vlan)
# -n: don't resolve names
# -l: buffered output
# not arp and not stp: filter out some noise if desired, but let's keep it simple
cmd = ["tcpdump", "-i", self.interface, "-e", "-n", "-l"]
self.process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
bufsize=1
)
start_time = time.time()
while self.running:
# Check timeout
if time.time() - start_time > self.timeout:
break
# Non-blocking read line
line = self.process.stdout.readline()
if not line and self.process.poll() is not None:
break
if line:
self.parse_line(line)
except Exception as e:
self.error_occurred.emit(str(e))
finally:
if self.process:
self.process.terminate()
try:
self.process.wait(timeout=1)
except:
self.process.kill()
self.scan_finished.emit()
def parse_line(self, line):
# Example Tagged: ... ethertype 802.1Q (0x8100), length 100: vlan 35, p 0, ethertype IPv4 ...
# Example Untagged: ... ethertype IPv4 (0x0800), length 100: ...
# Regex to find "vlan <id>"
match = re.search(r"vlan (\d+)", line)
if match:
vlan_id = int(match.group(1))
if vlan_id not in self.detected_vlans:
self.detected_vlans[vlan_id] = 0
self.detected_vlans[vlan_id] += 1
self.vlan_detected.emit(vlan_id, self.detected_vlans[vlan_id])
else:
# It's an untagged packet (or at least not 802.1Q)
# Filter out empty lines or weird output
if "ethertype" in line:
self.untagged_count += 1
self.untagged_detected.emit(self.untagged_count)
def stop(self):
self.running = False
if self.process:
self.process.kill() # Force kill to break readline()
self.wait()
class VLANScanner:
def __init__(self):
pass
def scan(self, interface, timeout=10):
"""Usa tcpdump en Linux (requiere root)."""
thread = VLANScannerThread(interface, timeout)
thread.start()
return thread
def scan_scapy(self, interface, timeout=10):
"""Usa Scapy; funciona en Windows (Npcap) y Linux."""
thread = VLANScannerThreadScapy(interface, timeout)
thread.start()
return thread
Binary file not shown.
Binary file not shown.
+198
View File
@@ -0,0 +1,198 @@
from PyQt6.QtWidgets import (
QMainWindow,
QWidget,
QVBoxLayout,
QTabWidget,
QLabel,
QHBoxLayout,
QStatusBar,
QFrame,
QFileDialog,
)
from PyQt6.QtCore import Qt
from src.core.network_manager import NetworkManager
from src.gui.widgets.interface_list import InterfaceListWidget
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.network_monitor import NetworkMonitorWidget
from src.gui.widgets.vlan35_check import VLAN35CheckWidget
from src.gui.widgets.vlan_detection import VLANDetectionWidget
from src.utils.report_generator import ReportGenerator
from src.utils.platform import is_windows
if not is_windows():
from src.gui.widgets.ip_config import IPConfigWidget
from src.gui.widgets.vlan_bridge import VLANBridgeWidget
from src.gui.widgets.advanced_routing import AdvancedRoutingWidget
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
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 y layout principal vertical
central_widget = QWidget()
self.setCentralWidget(central_widget)
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)
# Área principal de pestañas
self.tabs = QTabWidget()
main_layout.addWidget(self.tabs)
self.ip_config_widget = None
self.vlan_bridge_widget = None
self.advanced_routing_widget = None
if not is_windows():
self.ip_config_widget = IPConfigWidget(self.nm)
self.tabs.addTab(self.ip_config_widget, "Configuración IP")
self.vlan_bridge_widget = VLANBridgeWidget(self.nm)
self.tabs.addTab(self.vlan_bridge_widget, "VLAN / Puente")
else:
self.vlan_detection_widget = VLANDetectionWidget(self.nm)
self.tabs.addTab(self.vlan_detection_widget, "Detección VLAN")
self.scanner_widget = ScannerWidget()
self.tabs.addTab(self.scanner_widget, "Exploración de Red")
self.port_scanner_widget = PortScannerWidget()
self.tabs.addTab(self.port_scanner_widget, "Escáner de Puertos")
self.sniffer_widget = SnifferWidget(self.nm)
self.tabs.addTab(self.sniffer_widget, "Sniffer de Tráfico")
self.diagnostics_widget = DiagnosticsWidget()
self.tabs.addTab(self.diagnostics_widget, "Diagnóstico")
self.network_monitor_widget = NetworkMonitorWidget(self.nm)
self.tabs.addTab(self.network_monitor_widget, "Monitor de Red")
if not is_windows():
self.advanced_routing_widget = AdvancedRoutingWidget(self.nm)
self.tabs.addTab(self.advanced_routing_widget, "Routing Avanzado")
self.vlan35_check_widget = VLAN35CheckWidget()
self.tabs.addTab(self.vlan35_check_widget, "Comprobación VLAN35")
# Barra de herramientas para acciones globales
toolbar = self.addToolBar("Principal")
toolbar.setMovable(False)
toolbar.setFloatable(False)
toolbar.addAction("Generar informe HTML", self.generate_report)
# Barra de estado
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("Listo")
def on_interface_selected(self, iface_data):
self.status_bar.showMessage(f"Selected Interface: {iface_data['name']}")
if self.ip_config_widget:
self.ip_config_widget.set_interface(iface_data)
def closeEvent(self, event):
self.nm.close()
self.scanner_widget.close()
self.port_scanner_widget.close()
if self.vlan_bridge_widget:
self.vlan_bridge_widget.close()
if getattr(self, "vlan_detection_widget", None):
self.vlan_detection_widget.close()
event.accept()
def generate_report(self):
# Gather data
interfaces = self.nm.list_interfaces()
scan_results = []
for row in range(self.scanner_widget.table.rowCount()):
it0 = self.scanner_widget.table.item(row, 0)
it1 = self.scanner_widget.table.item(row, 1)
it2 = self.scanner_widget.table.item(row, 2)
it3 = self.scanner_widget.table.item(row, 3)
scan_results.append({
'ip': it0.text() if it0 else '',
'mac': it1.text() if it1 else '',
'vendor': it2.text() if it2 else '',
'status': it3.text() if it3 else ''
})
sniff_all = []
for row in range(self.sniffer_widget.table_all.rowCount()):
sniff_all.append({
'time': self.sniffer_widget.table_all.item(row, 0).text() if self.sniffer_widget.table_all.item(row, 0) else '',
'src': self.sniffer_widget.table_all.item(row, 1).text() if self.sniffer_widget.table_all.item(row, 1) else '',
'dst': self.sniffer_widget.table_all.item(row, 2).text() if self.sniffer_widget.table_all.item(row, 2) else '',
'proto': self.sniffer_widget.table_all.item(row, 3).text() if self.sniffer_widget.table_all.item(row, 3) else '',
'info': self.sniffer_widget.table_all.item(row, 4).text() if self.sniffer_widget.table_all.item(row, 4) else '',
'status': self.sniffer_widget.table_all.item(row, 5).text() if self.sniffer_widget.table_all.item(row, 5) else ''
})
sniff_failed = []
for row in range(self.sniffer_widget.table_failed.rowCount()):
sniff_failed.append({
'time': self.sniffer_widget.table_failed.item(row, 0).text() if self.sniffer_widget.table_failed.item(row, 0) else '',
'src': self.sniffer_widget.table_failed.item(row, 1).text() if self.sniffer_widget.table_failed.item(row, 1) else '',
'dst': self.sniffer_widget.table_failed.item(row, 2).text() if self.sniffer_widget.table_failed.item(row, 2) else '',
'proto': self.sniffer_widget.table_failed.item(row, 3).text() if self.sniffer_widget.table_failed.item(row, 3) else '',
'info': self.sniffer_widget.table_failed.item(row, 4).text() if self.sniffer_widget.table_failed.item(row, 4) else '',
'status': self.sniffer_widget.table_failed.item(row, 5).text() if self.sniffer_widget.table_failed.item(row, 5) else ''
})
generator = ReportGenerator()
filename, _ = QFileDialog.getSaveFileName(self, "Save Report", "report.html", "HTML Files (*.html)")
if filename:
success, msg = generator.generate_html_report(interfaces, scan_results, sniff_all, sniff_failed, filename)
if success:
self.status_bar.showMessage(f"Report saved to {msg}")
else:
self.status_bar.showMessage(f"Error saving report: {msg}")
+190
View File
@@ -0,0 +1,190 @@
from PyQt6.QtWidgets import QApplication
from PyQt6.QtGui import QPalette, QColor
from PyQt6.QtCore import Qt
def apply_styles(app: QApplication):
"""
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()
# 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)
palette.setColor(QPalette.ColorRole.AlternateBase, dark_bg)
palette.setColor(QPalette.ColorRole.ToolTipBase, text_color)
palette.setColor(QPalette.ColorRole.ToolTipText, text_color)
palette.setColor(QPalette.ColorRole.Text, text_color)
palette.setColor(QPalette.ColorRole.Button, dark_bg)
palette.setColor(QPalette.ColorRole.ButtonText, text_color)
palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red)
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 para dar un aspecto más “suite corporativa”
app.setStyleSheet("""
QMainWindow {
background-color: #101010;
}
QWidget {
font-family: 'Segoe UI', 'Roboto', 'Noto Sans', sans-serif;
font-size: 13px;
color: #e5e5e5;
}
QTabWidget::pane {
border: 1px solid #2b2b2b;
background: #181818;
border-radius: 4px;
top: -1px;
}
QTabBar::tab {
background: #151515;
color: #aaaaaa;
padding: 10px 20px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
margin-right: 2px;
font-weight: 600;
border: 1px solid transparent;
}
QTabBar::tab:selected {
background: #181818;
color: #ffffff;
border-top: 2px solid #008542;
border-bottom: 1px solid #181818;
}
QTabBar::tab:hover {
background: #202020;
color: #ffffff;
}
QPushButton {
background-color: #008542;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-weight: 600;
}
QPushButton:hover {
background-color: #009b4f;
}
QPushButton:pressed {
background-color: #006f39;
}
QPushButton:disabled {
background-color: #2a2a2a;
color: #666666;
}
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, 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: #141414;
border: 1px solid #2b2b2b;
border-radius: 4px;
gridline-color: #333333;
selection-background-color: #184b33;
selection-color: white;
alternate-background-color: #181818;
}
QHeaderView::section {
background-color: #181818;
color: #dddddd;
padding: 6px;
border: none;
border-right: 1px solid #2b2b2b;
border-bottom: 1px solid #2b2b2b;
font-weight: 600;
}
QListWidget {
background-color: #141414;
border: 1px solid #2b2b2b;
border-radius: 4px;
}
QListWidget::item {
padding: 8px;
border-bottom: 1px solid #202020;
}
QListWidget::item:selected {
background-color: #184b33;
color: white;
border-left: 2px solid #00a357;
}
QGroupBox {
border: 1px solid #3a3a3a;
border-radius: 6px;
font-weight: 600;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 0 5px;
color: #00a357;
left: 10px;
}
QStatusBar {
background-color: #101010;
color: white;
}
QProgressBar {
border: 1px solid #3a3a3a;
border-radius: 4px;
text-align: center;
background-color: #232323;
}
QProgressBar::chunk {
background-color: #00a357;
width: 10px;
}
QSplitter::handle {
background-color: #2b2b2b;
}
""")
+765
View File
@@ -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()
+182
View File
@@ -0,0 +1,182 @@
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QTextEdit, QComboBox,
QTabWidget, QSpinBox, QGroupBox)
from PyQt6.QtCore import QThread, pyqtSignal, Qt
from src.core.diagnostics import DiagnosticsManager
class DiagnosticsWorker(QThread):
output_signal = pyqtSignal(str)
finished_signal = pyqtSignal()
def __init__(self, func, *args):
super().__init__()
self.func = func
self.args = args
self.is_running = True
def run(self):
try:
for line in self.func(*self.args):
if not self.is_running:
break
self.output_signal.emit(line)
except Exception as e:
self.output_signal.emit(f"Error: {str(e)}")
finally:
self.finished_signal.emit()
def stop(self):
self.is_running = False
class DiagnosticsWidget(QWidget):
def __init__(self):
super().__init__()
self.dm = DiagnosticsManager()
self.worker = None
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
# Tabs for different tools
self.tabs = QTabWidget()
layout.addWidget(self.tabs)
# Ping Tab
self.ping_tab = QWidget()
self.init_ping_tab()
self.tabs.addTab(self.ping_tab, "Ping")
# Traceroute Tab
self.traceroute_tab = QWidget()
self.init_traceroute_tab()
self.tabs.addTab(self.traceroute_tab, "Traceroute")
# DNS Tab
self.dns_tab = QWidget()
self.init_dns_tab()
self.tabs.addTab(self.dns_tab, "DNS Lookup")
def init_ping_tab(self):
layout = QVBoxLayout(self.ping_tab)
# Controls
controls_layout = QHBoxLayout()
controls_layout.addWidget(QLabel("Host:"))
self.ping_host_input = QLineEdit()
self.ping_host_input.setPlaceholderText("e.g., google.com or 8.8.8.8")
controls_layout.addWidget(self.ping_host_input)
controls_layout.addWidget(QLabel("Count:"))
self.ping_count_input = QSpinBox()
self.ping_count_input.setRange(1, 100)
self.ping_count_input.setValue(4)
controls_layout.addWidget(self.ping_count_input)
self.ping_btn = QPushButton("Ping")
self.ping_btn.clicked.connect(self.start_ping)
controls_layout.addWidget(self.ping_btn)
layout.addLayout(controls_layout)
# Output
self.ping_output = QTextEdit()
self.ping_output.setReadOnly(True)
layout.addWidget(self.ping_output)
def init_traceroute_tab(self):
layout = QVBoxLayout(self.traceroute_tab)
# Controls
controls_layout = QHBoxLayout()
controls_layout.addWidget(QLabel("Host:"))
self.trace_host_input = QLineEdit()
self.trace_host_input.setPlaceholderText("e.g., google.com")
controls_layout.addWidget(self.trace_host_input)
self.trace_btn = QPushButton("Trace")
self.trace_btn.clicked.connect(self.start_traceroute)
controls_layout.addWidget(self.trace_btn)
layout.addLayout(controls_layout)
# Output
self.trace_output = QTextEdit()
self.trace_output.setReadOnly(True)
layout.addWidget(self.trace_output)
def init_dns_tab(self):
layout = QVBoxLayout(self.dns_tab)
# Controls
controls_layout = QHBoxLayout()
controls_layout.addWidget(QLabel("Host:"))
self.dns_host_input = QLineEdit()
self.dns_host_input.setPlaceholderText("e.g., google.com")
controls_layout.addWidget(self.dns_host_input)
controls_layout.addWidget(QLabel("Type:"))
self.dns_type_input = QComboBox()
self.dns_type_input.addItems(["A", "AAAA", "MX", "NS", "TXT", "CNAME", "SOA"])
controls_layout.addWidget(self.dns_type_input)
self.dns_btn = QPushButton("Lookup")
self.dns_btn.clicked.connect(self.start_dns)
controls_layout.addWidget(self.dns_btn)
layout.addLayout(controls_layout)
# Output
self.dns_output = QTextEdit()
self.dns_output.setReadOnly(True)
layout.addWidget(self.dns_output)
def start_ping(self):
host = self.ping_host_input.text().strip()
if not host:
self.ping_output.append("Please enter a host.")
return
count = self.ping_count_input.value()
self.ping_output.clear()
self.ping_output.append(f"Pinging {host}...")
self.ping_btn.setEnabled(False)
self.worker = DiagnosticsWorker(self.dm.run_ping, host, count)
self.worker.output_signal.connect(self.ping_output.append)
self.worker.finished_signal.connect(lambda: self.ping_btn.setEnabled(True))
self.worker.start()
def start_traceroute(self):
host = self.trace_host_input.text().strip()
if not host:
self.trace_output.append("Please enter a host.")
return
self.trace_output.clear()
self.trace_output.append(f"Tracing route to {host}...")
self.trace_btn.setEnabled(False)
self.worker = DiagnosticsWorker(self.dm.run_traceroute, host)
self.worker.output_signal.connect(self.trace_output.append)
self.worker.finished_signal.connect(lambda: self.trace_btn.setEnabled(True))
self.worker.start()
def start_dns(self):
host = self.dns_host_input.text().strip()
if not host:
self.dns_output.append("Please enter a host.")
return
query_type = self.dns_type_input.currentText()
self.dns_output.clear()
self.dns_output.append(f"Looking up {query_type} records for {host}...")
self.dns_btn.setEnabled(False)
self.worker = DiagnosticsWorker(self.dm.run_dns_lookup, host, query_type)
self.worker.output_signal.connect(self.dns_output.append)
self.worker.finished_signal.connect(lambda: self.dns_btn.setEnabled(True))
self.worker.start()
+145
View File
@@ -0,0 +1,145 @@
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
QLabel, QHBoxLayout, QFrame, QPushButton, QStyle, QMessageBox)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from src.utils.platform import is_windows
class InterfaceListWidget(QWidget):
interface_selected = pyqtSignal(dict)
def __init__(self, network_manager):
super().__init__()
self.nm = network_manager
self.layout = QVBoxLayout(self)
# Header
header_layout = QHBoxLayout()
title = QLabel("Network Interfaces")
title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold))
header_layout.addWidget(title)
header_layout.addStretch()
# Reset Button (solo Linux; en Windows no está disponible)
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)
if not is_windows():
header_layout.addWidget(self.reset_btn)
else:
self.reset_btn.setVisible(False)
# Refresh Button
self.refresh_btn = QPushButton()
self.refresh_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload))
self.refresh_btn.setToolTip("Refresh Interfaces")
self.refresh_btn.setFixedSize(30, 30)
self.refresh_btn.clicked.connect(self.refresh_interfaces)
header_layout.addWidget(self.refresh_btn)
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)
self.layout.addWidget(self.list_widget)
self.refresh_interfaces()
def refresh_interfaces(self):
self.list_widget.clear()
interfaces = self.nm.list_interfaces()
for iface in interfaces:
item = QListWidgetItem()
# Create custom widget for the item
item_widget = QWidget()
item_layout = QVBoxLayout(item_widget)
item_layout.setContentsMargins(5, 5, 5, 5)
# Top row: Name + Type + Status
top_row = QHBoxLayout()
name_label = QLabel(iface['name'])
name_label.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
type_label = QLabel(iface['type'])
type_label.setStyleSheet("color: #aaaaaa; font-size: 10px;")
status_color = "#4caf50" if iface['is_up'] else "#f44336"
status_label = QLabel("● " + iface['status'])
status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;")
# Config Method Badge (DHCP/Static)
config_method = iface.get('config', {}).get('method', 'unknown')
method_text = "DHCP" if config_method == 'auto' else "Static" if config_method == 'manual' else config_method.upper()
method_label = QLabel(f"[{method_text}]")
method_label.setStyleSheet("color: #e0e0e0; font-size: 10px; background-color: #3e3e3e; padding: 2px 4px; border-radius: 3px;")
top_row.addWidget(name_label)
top_row.addWidget(type_label)
top_row.addWidget(method_label)
top_row.addStretch()
top_row.addWidget(status_label)
# Bottom row: IP + MAC
bottom_row = QHBoxLayout()
ip_text = iface['ipv4'][0] if iface['ipv4'] else "No IP"
ip_label = QLabel(f"IP: {ip_text}")
ip_label.setStyleSheet("color: #0078d7;")
mac_label = QLabel(f"MAC: {iface['mac']}")
mac_label.setStyleSheet("color: #888888;")
bottom_row.addWidget(ip_label)
bottom_row.addStretch()
bottom_row.addWidget(mac_label)
item_layout.addLayout(top_row)
item_layout.addLayout(bottom_row)
item.setSizeHint(item_widget.sizeHint())
self.list_widget.addItem(item)
self.list_widget.setItemWidget(item, item_widget)
# Store data in item
item.setData(Qt.ItemDataRole.UserRole, iface)
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)
+165
View File
@@ -0,0 +1,165 @@
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QRadioButton, QButtonGroup,
QGroupBox, QFormLayout, QMessageBox)
from PyQt6.QtCore import Qt
import ipaddress
class IPConfigWidget(QWidget):
def __init__(self, network_manager):
super().__init__()
self.nm = network_manager
self.current_iface = None
self.layout = QVBoxLayout(self)
# IP Configuration Group
config_group = QGroupBox("IP Configuration")
config_layout = QVBoxLayout(config_group)
# Mode Selection
mode_layout = QHBoxLayout()
self.dhcp_radio = QRadioButton("DHCP")
self.static_radio = QRadioButton("Static")
self.static_radio.setChecked(True)
self.mode_group = QButtonGroup()
self.mode_group.addButton(self.dhcp_radio)
self.mode_group.addButton(self.static_radio)
mode_layout.addWidget(self.dhcp_radio)
mode_layout.addWidget(self.static_radio)
mode_layout.addStretch()
config_layout.addLayout(mode_layout)
# Static IP Fields
self.form_layout = QFormLayout()
self.ip_input = QLineEdit()
self.cidr_input = QLineEdit()
self.cidr_input.setPlaceholderText("24")
self.gateway_input = QLineEdit()
self.form_layout.addRow("IP Address:", self.ip_input)
self.form_layout.addRow("CIDR Prefix:", self.cidr_input)
self.form_layout.addRow("Gateway:", self.gateway_input)
# DNS Field
self.dns_input = QLineEdit()
self.form_layout.addRow("DNS:", self.dns_input)
config_layout.addLayout(self.form_layout)
# Apply Button
self.apply_btn = QPushButton("Apply Configuration")
self.apply_btn.clicked.connect(self.apply_config)
config_layout.addWidget(self.apply_btn)
self.layout.addWidget(config_group)
# Subnet Calculator Group
calc_group = QGroupBox("Subnet Calculator")
calc_layout = QVBoxLayout(calc_group)
calc_form = QFormLayout()
self.calc_ip = QLineEdit()
self.calc_cidr = QLineEdit()
self.calc_cidr.setPlaceholderText("24")
self.calc_netmask = QLineEdit()
self.calc_netmask.setReadOnly(True)
self.calc_network = QLineEdit()
self.calc_network.setReadOnly(True)
self.calc_broadcast = QLineEdit()
self.calc_broadcast.setReadOnly(True)
calc_form.addRow("IP Address:", self.calc_ip)
calc_form.addRow("CIDR:", self.calc_cidr)
calc_form.addRow("Netmask:", self.calc_netmask)
calc_form.addRow("Network:", self.calc_network)
calc_form.addRow("Broadcast:", self.calc_broadcast)
calc_layout.addLayout(calc_form)
# Calculate Button (or auto-calc)
self.calc_btn = QPushButton("Calculate")
self.calc_btn.clicked.connect(self.calculate_subnet)
calc_layout.addWidget(self.calc_btn)
self.layout.addWidget(calc_group)
self.layout.addStretch()
# Connect signals
self.dhcp_radio.toggled.connect(self.toggle_inputs)
self.static_radio.toggled.connect(self.toggle_inputs)
def set_interface(self, iface_data):
self.current_iface = iface_data
# Get real config from NM
config = iface_data.get('config', {})
method = config.get('method', 'auto')
if method == 'manual':
self.static_radio.setChecked(True)
self.ip_input.setText(config.get('ip', ''))
self.cidr_input.setText(config.get('prefix', ''))
self.gateway_input.setText(config.get('gateway', ''))
self.dns_input.setText(config.get('dns', ''))
else:
self.dhcp_radio.setChecked(True)
# Clear fields or show current lease?
# For now, let's show current IP even if DHCP
if iface_data['ipv4']:
ip, prefix = iface_data['ipv4'][0].split('/')
self.ip_input.setText(ip)
self.cidr_input.setText(prefix)
# Update calculator
self.calc_ip.setText(self.ip_input.text())
self.calc_cidr.setText(self.cidr_input.text())
self.calculate_subnet()
def toggle_inputs(self):
enabled = self.static_radio.isChecked()
self.ip_input.setEnabled(enabled)
self.cidr_input.setEnabled(enabled)
self.gateway_input.setEnabled(enabled)
self.dns_input.setEnabled(enabled)
def calculate_subnet(self):
ip = self.calc_ip.text()
cidr = self.calc_cidr.text()
if not ip or not cidr:
return
try:
network = ipaddress.IPv4Interface(f"{ip}/{cidr}")
self.calc_netmask.setText(str(network.netmask))
self.calc_network.setText(str(network.network))
self.calc_broadcast.setText(str(network.network.broadcast_address))
except ValueError as e:
self.calc_netmask.setText("Invalid IP/CIDR")
def apply_config(self):
if not self.current_iface:
QMessageBox.warning(self, "Error", "No interface selected")
return
iface_name = self.current_iface['name']
try:
if self.dhcp_radio.isChecked():
success, msg = self.nm.apply_interface_config(iface_name, 'auto')
else:
ip = self.ip_input.text()
cidr = self.cidr_input.text()
gateway = self.gateway_input.text()
dns = self.dns_input.text()
success, msg = self.nm.apply_interface_config(iface_name, 'manual', ip, cidr, gateway, dns)
if success:
QMessageBox.information(self, "Success", msg)
else:
QMessageBox.critical(self, "Error", msg)
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
+181
View File
@@ -0,0 +1,181 @@
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
from src.utils.platform import is_windows
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)
self.fw_group = None
self.fw_table = None
if not is_windows():
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)
self.fw_group = 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)
if not is_windows():
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 (solo Linux). En Windows no se muestra.
"""
if not self.fw_table:
return
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"]))
+324
View File
@@ -0,0 +1,324 @@
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QComboBox, QTableWidget,
QTableWidgetItem, QHeaderView, QMessageBox, QGroupBox, QTextEdit)
from PyQt6.QtCore import Qt
from src.core.scanner import NetworkScanner
class PortScannerWidget(QWidget):
def __init__(self):
super().__init__()
self.scanner = NetworkScanner()
self.scan_thread = None
self.layout = QVBoxLayout(self)
# Controls Group
controls_group = QGroupBox("Target Configuration")
controls_layout = QVBoxLayout(controls_group)
# Target IP
target_layout = QHBoxLayout()
target_layout.addWidget(QLabel("Target IP:"))
self.target_input = QLineEdit()
self.target_input.setPlaceholderText("e.g., 192.168.1.50")
target_layout.addWidget(self.target_input)
controls_layout.addLayout(target_layout)
# Ports
ports_layout = QHBoxLayout()
ports_layout.addWidget(QLabel("Ports:"))
self.ports_input = QLineEdit()
self.ports_input.setPlaceholderText("e.g., 80, 443, 1000-2000")
ports_layout.addWidget(self.ports_input)
controls_layout.addLayout(ports_layout)
# Protocol & Button
action_layout = QHBoxLayout()
action_layout.addWidget(QLabel("Protocol:"))
self.protocol_combo = QComboBox()
self.protocol_combo.addItems(["TCP", "UDP", "Both"])
action_layout.addWidget(self.protocol_combo)
self.scan_btn = QPushButton("Start Port Scan")
self.scan_btn.clicked.connect(self.start_scan)
action_layout.addWidget(self.scan_btn)
controls_layout.addLayout(action_layout)
self.layout.addWidget(controls_group)
# Filter & Search Group
filter_layout = QHBoxLayout()
filter_layout.addWidget(QLabel("Filter:"))
self.filter_combo = QComboBox()
self.filter_combo.addItems(["All", "Open", "Closed"])
self.filter_combo.currentTextChanged.connect(self.apply_filters)
filter_layout.addWidget(self.filter_combo)
filter_layout.addWidget(QLabel("Search Port:"))
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Search port number...")
self.search_input.textChanged.connect(self.apply_filters)
filter_layout.addWidget(self.search_input)
self.layout.addLayout(filter_layout)
# Results Table
self.table = QTableWidget()
self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels(["Port", "Protocol", "State", "Service"])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.layout.addWidget(self.table)
# Progress Log
log_group = QGroupBox("Scan Progress")
log_layout = QVBoxLayout(log_group)
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setMaximumHeight(150)
self.log_output.setStyleSheet("background-color: #1e1e1e; color: #00ff00; font-family: monospace;")
log_layout.addWidget(self.log_output)
self.layout.addWidget(log_group)
def start_scan(self):
if self.scan_btn.text() == "Stop Scan":
self.stop_scan()
return
target = self.target_input.text().strip()
ports = self.ports_input.text().strip()
protocol = self.protocol_combo.currentText()
if not target:
QMessageBox.warning(self, "Error", "Please enter a target IP.")
return
if not ports:
QMessageBox.warning(self, "Error", "Please enter ports to scan.")
return
self.scan_btn.setText("Stop Scan")
self.scan_btn.setStyleSheet("background-color: #800000; color: white;") # Red for stop
self.table.setRowCount(0)
self.log_output.clear()
self.current_target = target
self.current_ports = ports
self.chain_udp = False
if protocol == "Both":
self.chain_udp = True
self.run_scan(target, ports, "TCP")
else:
self.run_scan(target, ports, protocol)
def run_scan(self, target, ports, protocol):
self.log_output.append(f"--- Starting {protocol} Scan ---\n")
# Port Scanner should use skip_discovery=True (-Pn) to ensure it scans even if ping blocked
self.scan_thread = self.scanner.scan(target, ports, protocol, skip_discovery=True)
self.scan_thread.scan_finished.connect(self.on_scan_finished)
self.scan_thread.scan_error.connect(self.on_scan_error)
self.scan_thread.scan_progress.connect(self.on_scan_progress)
self.scan_thread.port_found.connect(self.on_port_found)
def stop_scan(self):
self.chain_udp = False # Cancel chain
if self.scan_thread:
self.scan_thread.stop()
self.scan_btn.setEnabled(False)
self.scan_btn.setText("Stopping...")
def on_scan_progress(self, msg):
self.log_output.append(msg)
sb = self.log_output.verticalScrollBar()
sb.setValue(sb.maximum())
def on_port_found(self, port, proto, state, service):
# Check if already exists
items = self.table.findItems(str(port), Qt.MatchFlag.MatchExactly)
for item in items:
if item.column() == 0:
row = item.row()
if self.table.item(row, 1).text() == proto:
return
self.add_result_row(port, proto, state, service)
def on_scan_finished(self, result):
# 1. Process XML results
found_ports = set()
if result and 'scan' in result:
for ip, data in result['scan'].items():
if 'tcp' in data:
for port, info in data['tcp'].items():
self.update_or_add_row(port, "TCP", info['state'], info.get('name', ''))
found_ports.add((int(port), "TCP"))
if 'udp' in data:
for port, info in data['udp'].items():
self.update_or_add_row(port, "UDP", info['state'], info.get('name', ''))
found_ports.add((int(port), "UDP"))
# 2. Chain UDP if needed
if self.chain_udp:
self.chain_udp = False
self.run_scan(self.current_target, self.current_ports, "UDP")
return
# 3. Finalize (only after all scans done)
self.finalize_scan(found_ports)
def finalize_scan(self, found_ports_unused):
self.scan_btn.setEnabled(True)
self.scan_btn.setText("Start Port Scan")
self.scan_btn.setStyleSheet("")
# Disable updates to prevent freeze
self.table.setUpdatesEnabled(False)
try:
# 1. Build set of ALREADY FOUND ports from the table
existing_ports = set()
for row in range(self.table.rowCount()):
port_item = self.table.item(row, 0)
proto_item = self.table.item(row, 1)
if port_item and proto_item:
existing_ports.add((int(port_item.text()), proto_item.text()))
# 2. Parse requested ports
requested_ports_str = self.ports_input.text().strip()
requested_proto = self.protocol_combo.currentText()
target_ports = set()
try:
parts = requested_ports_str.split(',')
for part in parts:
if '-' in part:
start, end = map(int, part.split('-'))
target_ports.update(range(start, end + 1))
else:
target_ports.add(int(part))
except:
pass
# Determine protocols
protos = []
if requested_proto == "TCP" or requested_proto == "Both":
protos.append("TCP")
if requested_proto == "UDP" or requested_proto == "Both":
protos.append("UDP")
# 3. Add closed rows for missing ports
# Only show closed ports if the total number of ports is reasonable (< 500)
# to prevent UI freezing and overwhelming the user.
if len(target_ports) > 500:
self.log_output.append(f"\n[INFO] Large range ({len(target_ports)} ports). Hiding closed ports.")
else:
self.table.setSortingEnabled(False)
for port in target_ports:
for proto in protos:
if (port, proto) not in existing_ports:
# Pass update_filter=False for bulk addition
self.add_result_row(port, proto, "closed", "", update_filter=False)
self.table.setSortingEnabled(True)
self.apply_filters() # Apply once at the end
finally:
self.table.setUpdatesEnabled(True)
def row_exists(self, port, proto):
# Deprecated / Unused in optimized version but kept for safety if needed elsewhere
items = self.table.findItems(str(port), Qt.MatchFlag.MatchExactly)
for item in items:
if item.column() == 0:
if self.table.item(item.row(), 1).text() == proto:
return True
return False
def update_or_add_row(self, port, proto, state, service):
# Find row
items = self.table.findItems(str(port), Qt.MatchFlag.MatchExactly)
found_row = -1
for item in items:
if item.column() == 0:
if self.table.item(item.row(), 1).text() == proto:
found_row = item.row()
break
if found_row >= 0:
# Update
self.update_row_style(found_row, state)
self.table.setItem(found_row, 3, QTableWidgetItem(service))
else:
# Add
self.add_result_row(port, proto, state, service)
def add_result_row(self, port, proto, state, service, update_filter=True):
row = self.table.rowCount()
self.table.insertRow(row)
self.table.setItem(row, 0, QTableWidgetItem(str(port)))
self.table.setItem(row, 1, QTableWidgetItem(proto))
state_item = QTableWidgetItem(state)
self.table.setItem(row, 2, state_item)
self.table.setItem(row, 3, QTableWidgetItem(service))
self.update_row_style(row, state)
if update_filter:
self.apply_filters() # Re-apply filters to new row
def update_row_style(self, row, state):
state_item = self.table.item(row, 2)
if state == 'open':
state_item.setForeground(Qt.GlobalColor.green)
state_item.setText("open")
elif state == 'closed':
state_item.setForeground(Qt.GlobalColor.red)
state_item.setText("closed")
else:
state_item.setForeground(Qt.GlobalColor.yellow)
state_item.setText(state)
def apply_filters(self):
filter_state = self.filter_combo.currentText().lower()
search_text = self.search_input.text().strip()
for i in range(self.table.rowCount()):
show = True
# State Filter
state_item = self.table.item(i, 2)
state = state_item.text()
if filter_state != "all":
if filter_state == "open" and state != "open":
show = False
elif filter_state == "closed" and state != "closed":
show = False
# Search Filter
if show and search_text:
port_item = self.table.item(i, 0)
if search_text not in port_item.text():
show = False
self.table.setRowHidden(i, not show)
def on_scan_error(self, msg):
self.chain_udp = False # Stop chain on error
self.scan_btn.setEnabled(True)
self.scan_btn.setText("Start Port Scan")
self.scan_btn.setStyleSheet("")
if "kill" in str(msg) or "None" in str(msg) or "stopped by user" in str(msg):
return
QMessageBox.critical(self, "Error", f"Scan failed: {msg}")
def closeEvent(self, event):
if self.scan_thread and self.scan_thread.isRunning():
self.scan_thread.stop()
event.accept()
+163
View File
@@ -0,0 +1,163 @@
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QTableWidget, QTableWidgetItem,
QHeaderView, QMessageBox, QProgressBar, QComboBox, QTextEdit, QGroupBox)
from src.core.scanner import NetworkScanner
class ScannerWidget(QWidget):
def __init__(self):
super().__init__()
self.scanner = NetworkScanner()
self.scan_thread = None
self.layout = QVBoxLayout(self)
# Controls
controls_layout = QHBoxLayout()
self.target_input = QLineEdit()
self.target_input.setPlaceholderText("Target Network (e.g. 192.168.1.0/24)")
self.scan_btn = QPushButton("Scan Network")
self.scan_btn.clicked.connect(self.start_scan)
self.filter_combo = QComboBox()
self.filter_combo.addItems(["All", "Up", "Down"])
self.filter_combo.currentTextChanged.connect(self.apply_filters)
controls_layout.addWidget(QLabel("Target:"))
controls_layout.addWidget(self.target_input)
controls_layout.addWidget(QLabel("Filter:"))
controls_layout.addWidget(self.filter_combo)
controls_layout.addWidget(self.scan_btn)
self.layout.addLayout(controls_layout)
# Progress Bar
self.progress = QProgressBar()
self.progress.setRange(0, 0) # Indeterminate
self.progress.hide()
self.layout.addWidget(self.progress)
# Results Table
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(["IP", "MAC", "Vendor", "Status", "Open Ports"])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.layout.addWidget(self.table)
# Progress Log
log_group = QGroupBox("Scan Progress")
log_layout = QVBoxLayout(log_group)
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setMaximumHeight(150)
self.log_output.setStyleSheet("background-color: #1e1e1e; color: #00ff00; font-family: monospace;")
log_layout.addWidget(self.log_output)
self.layout.addWidget(log_group)
def start_scan(self):
if self.scan_btn.text() == "Stop Scan":
if self.scan_thread:
self.scan_thread.stop()
self.scan_btn.setEnabled(False)
self.scan_btn.setText("Stopping...")
return
target = self.target_input.text()
if not target:
QMessageBox.warning(self, "Error", "Please enter a target IP range.")
return
self.scan_btn.setText("Stop Scan")
self.scan_btn.setStyleSheet("background-color: #800000; color: white;")
self.progress.show()
self.table.setRowCount(0)
self.log_output.clear()
# Use default scan (TCP, Fast ports) for discovery
# skip_discovery=False ensures we ONLY scan hosts that are UP (ARP/Ping response)
self.scan_thread = self.scanner.scan(target, ports=None, scan_type="TCP", skip_discovery=False)
self.scan_thread.scan_finished.connect(self.on_scan_finished)
self.scan_thread.scan_error.connect(self.on_scan_error)
self.scan_thread.scan_progress.connect(self.on_scan_progress)
def on_scan_progress(self, msg):
self.log_output.append(msg)
sb = self.log_output.verticalScrollBar()
sb.setValue(sb.maximum())
def on_scan_finished(self, result):
self.scan_btn.setEnabled(True)
self.scan_btn.setText("Scan Network")
self.scan_btn.setStyleSheet("")
self.progress.hide()
scan_data = result.get('scan', {})
self.table.setRowCount(len(scan_data))
for i, (ip, data) in enumerate(scan_data.items()):
mac = "Unknown"
vendor = "Unknown"
status = data['status']['state']
if 'addresses' in data and 'mac' in data['addresses']:
mac = data['addresses']['mac']
if 'vendor' in data and mac in data['vendor']:
vendor = data['vendor'][mac]
# Collect Open Ports
open_ports = []
if 'tcp' in data:
for port, info in data['tcp'].items():
if info['state'] == 'open':
open_ports.append(f"{port}/tcp")
if 'udp' in data:
for port, info in data['udp'].items():
if info['state'] == 'open':
open_ports.append(f"{port}/udp")
ports_str = ", ".join(open_ports) if open_ports else ""
self.table.setItem(i, 0, QTableWidgetItem(ip))
self.table.setItem(i, 1, QTableWidgetItem(mac))
self.table.setItem(i, 2, QTableWidgetItem(vendor))
self.table.setItem(i, 3, QTableWidgetItem(status))
self.table.setItem(i, 4, QTableWidgetItem(ports_str))
self.apply_filters()
def apply_filters(self):
filter_text = self.filter_combo.currentText().lower()
for row in range(self.table.rowCount()):
status_item = self.table.item(row, 3)
if not status_item:
continue
status = status_item.text().lower()
if filter_text == "all":
self.table.setRowHidden(row, False)
elif filter_text == "up" and status == "up":
self.table.setRowHidden(row, False)
elif filter_text == "down" and status == "down":
self.table.setRowHidden(row, False)
else:
self.table.setRowHidden(row, True)
def on_scan_error(self, error):
self.scan_btn.setEnabled(True)
self.scan_btn.setText("Scan Network")
self.scan_btn.setStyleSheet("")
self.progress.hide()
if "stopped by user" in str(error):
return
QMessageBox.critical(self, "Scan Error", error)
def closeEvent(self, event):
if self.scan_thread and self.scan_thread.isRunning():
self.scan_thread.stop()
event.accept()
+162
View File
@@ -0,0 +1,162 @@
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QComboBox, QPushButton, QTableWidget, QTableWidgetItem,
QHeaderView, QLineEdit, QMessageBox, QCheckBox, QSplitter, QGroupBox)
from PyQt6.QtGui import QColor
from PyQt6.QtCore import Qt
from src.core.sniffer import NetworkSniffer
class SnifferWidget(QWidget):
def __init__(self, network_manager):
super().__init__()
self.nm = network_manager
self.sniffer = NetworkSniffer()
self.is_sniffing = False
self.layout = QVBoxLayout(self)
# Controls
controls_layout = QHBoxLayout()
self.iface_combo = QComboBox()
self.refresh_interfaces()
self.filter_input = QLineEdit()
self.filter_input.setPlaceholderText("BPF Filter (e.g. tcp port 80)")
self.ip_filter_input = QLineEdit()
self.ip_filter_input.setPlaceholderText("Filter IP (Src/Dst)")
self.ip_filter_input.textChanged.connect(self.apply_ip_filter)
self.start_btn = QPushButton("Start Sniffing")
self.start_btn.clicked.connect(self.toggle_sniffing)
controls_layout.addWidget(QLabel("Interface:"))
controls_layout.addWidget(self.iface_combo)
controls_layout.addWidget(QLabel("BPF:"))
controls_layout.addWidget(self.filter_input)
controls_layout.addWidget(QLabel("IP Filter:"))
controls_layout.addWidget(self.ip_filter_input)
controls_layout.addWidget(self.start_btn)
self.layout.addLayout(controls_layout)
# Splitter for Dual Consoles
self.splitter = QSplitter(Qt.Orientation.Vertical)
self.layout.addWidget(self.splitter)
# Top Console: All Traffic
self.all_traffic_group = QGroupBox("All Traffic")
self.all_traffic_layout = QVBoxLayout(self.all_traffic_group)
self.table_all = self.create_table()
self.all_traffic_layout.addWidget(self.table_all)
self.splitter.addWidget(self.all_traffic_group)
# Bottom Console: Failed / Slow / Anomalies
self.failed_traffic_group = QGroupBox("Failed / Slow / Anomalies")
self.failed_traffic_layout = QVBoxLayout(self.failed_traffic_group)
self.table_failed = self.create_table()
self.failed_traffic_layout.addWidget(self.table_failed)
self.splitter.addWidget(self.failed_traffic_group)
# Set initial sizes
self.splitter.setSizes([400, 200])
def create_table(self):
table = QTableWidget()
table.setColumnCount(6)
table.setHorizontalHeaderLabels(["Time", "Source", "Destination", "Protocol", "Info", "Status"])
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
table.horizontalHeader().setStretchLastSection(True)
table.setAlternatingRowColors(True)
return table
def refresh_interfaces(self):
self.iface_combo.clear()
interfaces = self.nm.list_interfaces()
for iface in interfaces:
self.iface_combo.addItem(iface['name'])
def toggle_sniffing(self):
if self.is_sniffing:
self.sniffer.stop_sniffing()
self.start_btn.setText("Start Sniffing")
self.start_btn.setStyleSheet("background-color: #007acc;")
self.is_sniffing = False
else:
iface = self.iface_combo.currentText()
filter_str = self.filter_input.text()
self.table_all.setRowCount(0)
self.table_failed.setRowCount(0)
self.sniffer_thread = self.sniffer.start_sniffing(iface, filter_str)
self.sniffer_thread.packet_received.connect(self.add_packet)
self.sniffer_thread.error_occurred.connect(self.on_error)
self.start_btn.setText("Stop Sniffing")
self.start_btn.setStyleSheet("background-color: #d32f2f;")
self.is_sniffing = True
def add_packet(self, pkt):
# Add to All Traffic
self.insert_packet_row(self.table_all, pkt)
# Check if failed/slow/anomaly
status = pkt.get('status', '')
if status in ['Error', 'Connecting']:
self.insert_packet_row(self.table_failed, pkt)
def insert_packet_row(self, table, pkt):
row = table.rowCount()
table.insertRow(row)
table.setItem(row, 0, QTableWidgetItem(pkt['time']))
table.setItem(row, 1, QTableWidgetItem(pkt['src']))
table.setItem(row, 2, QTableWidgetItem(pkt['dst']))
table.setItem(row, 3, QTableWidgetItem(str(pkt['proto'])))
table.setItem(row, 4, QTableWidgetItem(pkt['info']))
status_item = QTableWidgetItem(pkt.get('status', ''))
if pkt.get('status') == 'Error':
status_item.setForeground(QColor('#ff5252')) # Red
elif pkt.get('status') == 'Connecting':
status_item.setForeground(QColor('#ffab40')) # Orange
elif pkt.get('status') == 'OK':
status_item.setForeground(QColor('#69f0ae')) # Green
table.setItem(row, 5, status_item)
# Check filter immediately
self.check_filter_for_row(table, row, pkt['src'], pkt['dst'])
if not table.isRowHidden(row):
table.scrollToBottom()
def apply_ip_filter(self):
filter_ip = self.ip_filter_input.text().strip()
for table in [self.table_all, self.table_failed]:
for row in range(table.rowCount()):
src = table.item(row, 1).text()
dst = table.item(row, 2).text()
if not filter_ip:
table.setRowHidden(row, False)
elif filter_ip in src or filter_ip in dst:
table.setRowHidden(row, False)
else:
table.setRowHidden(row, True)
def check_filter_for_row(self, table, row, src, dst):
filter_ip = self.ip_filter_input.text().strip()
if not filter_ip:
table.setRowHidden(row, False)
return
if filter_ip in src or filter_ip in dst:
table.setRowHidden(row, False)
else:
table.setRowHidden(row, True)
def on_error(self, msg):
self.toggle_sniffing()
QMessageBox.critical(self, "Sniffer Error", msg)
+565
View File
@@ -0,0 +1,565 @@
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 = []
self._updating_table = False
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)
save_config_btn = QPushButton("Guardar Configuración")
save_config_btn.clicked.connect(lambda: self.save_current_tests(show_feedback=True))
action_layout.addWidget(save_config_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
)
self.tests_table.itemChanged.connect(self.on_test_item_changed)
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 guardados al iniciar; si no hay, usa defaults
self.load_saved_tests()
def load_default_tests(self):
"""Carga los tests por defecto del checker."""
tests = self.checker.get_default_tests()
self.populate_tests_table(tests)
self.save_current_tests()
def load_saved_tests(self):
"""Carga tests persistidos en disco."""
tests = self.checker.load_tests()
self.populate_tests_table(tests)
def populate_tests_table(self, tests: list):
"""Rellena tabla de pruebas sin disparar auto-guardado por cada celda."""
self._updating_table = True
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")))
self.tests_table.setCellWidget(row, 4, self._build_delete_button())
self._updating_table = False
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))
self.tests_table.setCellWidget(row, 4, self._build_delete_button())
# Limpiar formulario
self.new_host_input.clear()
self.new_port_spin.setValue(80)
self.save_current_tests()
def remove_test(self, row: int):
"""Elimina un test de la tabla."""
self.tests_table.removeRow(row)
self.save_current_tests()
def remove_test_from_button(self):
"""Elimina la fila asociada al botón pulsado."""
button = self.sender()
if not button:
return
index = self.tests_table.indexAt(button.pos())
if index.isValid():
self.remove_test(index.row())
def _build_delete_button(self) -> QPushButton:
delete_btn = QPushButton("Eliminar")
delete_btn.setStyleSheet("background-color: #800000; color: white; padding: 2px 6px;")
delete_btn.clicked.connect(self.remove_test_from_button)
return delete_btn
def on_test_item_changed(self, item: QTableWidgetItem):
"""Guarda cambios cuando el usuario edita host/puerto/protocolo/tipo."""
if self._updating_table:
return
if item.column() in (0, 1, 2, 3):
self.save_current_tests()
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.save_current_tests()
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 save_current_tests(self, show_feedback: bool = False):
"""Guarda configuracion actual de pruebas."""
tests = self.get_tests_from_table()
ok, msg = self.checker.save_tests(tests)
if show_feedback:
if ok:
QMessageBox.information(self, "Configuración Guardada", msg)
else:
QMessageBox.warning(self, "Error al Guardar", msg)
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
+510
View File
@@ -0,0 +1,510 @@
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QComboBox, QSpinBox, QPushButton, QGroupBox,
QMessageBox, QFormLayout, QLineEdit, QTableWidget,
QTableWidgetItem, QHeaderView, QCheckBox, QSplitter)
from PyQt6.QtCore import Qt, QTimer
from src.core.vlan_scanner import VLANScanner
class VLANBridgeWidget(QWidget):
def __init__(self, network_manager):
super().__init__()
self.nm = network_manager
self.vlan_scanner = VLANScanner()
self.scan_thread = None
self.layout = QVBoxLayout(self)
# Create a horizontal layout for the top part (Bridge + Wifi)
self.splitter = QSplitter(Qt.Orientation.Vertical)
self.layout.addWidget(self.splitter)
# Top Container Widget
top_widget = QWidget()
top_layout = QHBoxLayout(top_widget)
top_layout.setContentsMargins(0, 0, 0, 0)
# Left Column: Wired Bridging
left_col = QVBoxLayout()
# Wired Bridging Group
wired_group = QGroupBox("Wired Bridging")
wired_layout = QVBoxLayout(wired_group)
wired_layout.setSpacing(5)
wired_layout.setContentsMargins(10, 15, 10, 10)
# Mode Selection
mode_layout = QHBoxLayout()
mode_layout.addWidget(QLabel("Mode:"))
self.mode_combo = QComboBox()
self.mode_combo.addItems(["VLAN Trunk (Trunk -> Access)", "Passthrough (Untagged -> Untagged)"])
self.mode_combo.currentIndexChanged.connect(self.toggle_mode_inputs)
mode_layout.addWidget(self.mode_combo)
wired_layout.addLayout(mode_layout)
# Interface Selection
form_layout = QFormLayout()
form_layout.setSpacing(5)
self.trunk_combo = QComboBox() # Input
self.trunk_combo.currentIndexChanged.connect(self.on_trunk_combo_changed)
self.access_combo = QComboBox() # Output
self.lbl_input = QLabel("Input Interface (Trunk):")
self.lbl_output = QLabel("Output Interface (Access):")
form_layout.addRow(self.lbl_input, self.trunk_combo)
form_layout.addRow(self.lbl_output, self.access_combo)
# VLAN ID Input
self.vlan_input = QSpinBox()
self.vlan_input.setRange(1, 4094)
self.lbl_vlan = QLabel("VLAN ID:")
form_layout.addRow(self.lbl_vlan, self.vlan_input)
wired_layout.addLayout(form_layout)
# IP / DHCP Configuration
ip_group = QGroupBox("Bridge IP & DHCP Server")
ip_layout = QFormLayout(ip_group)
ip_layout.setSpacing(5)
self.enable_dhcp_cb = QCheckBox("Enable DHCP Server & Static IP")
self.enable_dhcp_cb.toggled.connect(self.toggle_dhcp_inputs)
ip_layout.addRow(self.enable_dhcp_cb)
self.bridge_ip = QLineEdit("192.168.50.1")
self.dhcp_start = QLineEdit("192.168.50.10")
self.dhcp_end = QLineEdit("192.168.50.100")
ip_layout.addRow("Bridge IP (Gateway):", self.bridge_ip)
ip_layout.addRow("DHCP Start:", self.dhcp_start)
ip_layout.addRow("DHCP End:", self.dhcp_end)
wired_layout.addWidget(ip_group)
self.ip_group = ip_group
# Initialize inputs state
self.toggle_dhcp_inputs(False)
self.bridge_btn = QPushButton("Create Bridge")
self.bridge_btn.clicked.connect(self.create_bridge)
wired_layout.addWidget(self.bridge_btn)
# Bridge Management Group
manage_group = QGroupBox("Manage Bridges")
manage_layout = QHBoxLayout(manage_group)
manage_layout.setContentsMargins(5, 15, 5, 5)
self.active_bridges_combo = QComboBox()
self.delete_bridge_btn = QPushButton("Delete Bridge")
self.delete_bridge_btn.setStyleSheet("background-color: #800000; color: white;")
self.delete_bridge_btn.clicked.connect(self.delete_bridge)
manage_layout.addWidget(QLabel("Active Bridges:"))
manage_layout.addWidget(self.active_bridges_combo)
manage_layout.addWidget(self.delete_bridge_btn)
wired_layout.addWidget(manage_group)
left_col.addWidget(wired_group)
top_layout.addLayout(left_col)
# Right Column: Wi-Fi Bridging
right_col = QVBoxLayout()
wifi_group = QGroupBox("Wi-Fi AP Bridging")
wifi_layout = QVBoxLayout(wifi_group)
wifi_layout.setSpacing(5)
wifi_layout.setContentsMargins(10, 15, 10, 10)
wifi_form = QFormLayout()
wifi_form.setSpacing(5)
self.wifi_iface_combo = QComboBox()
self.wifi_iface_combo.currentIndexChanged.connect(self.on_wifi_iface_changed)
self.ssid_input = QLineEdit()
self.password_input = QLineEdit()
self.password_input.setEchoMode(QLineEdit.EchoMode.Password)
self.bridge_select_combo = QComboBox() # Select which bridge to attach to
wifi_form.addRow("Wi-Fi Interface:", self.wifi_iface_combo)
wifi_form.addRow("SSID:", self.ssid_input)
wifi_form.addRow("Password:", self.password_input)
wifi_form.addRow("Attach to Bridge:", self.bridge_select_combo)
wifi_layout.addLayout(wifi_form)
# Wi-Fi DHCP Configuration
wifi_dhcp_group = QGroupBox("Wi-Fi DHCP Server")
wifi_dhcp_layout = QFormLayout(wifi_dhcp_group)
wifi_dhcp_layout.setSpacing(5)
self.wifi_enable_dhcp_cb = QCheckBox("Enable DHCP on Bridge")
self.wifi_enable_dhcp_cb.toggled.connect(self.toggle_wifi_dhcp_inputs)
wifi_dhcp_layout.addRow(self.wifi_enable_dhcp_cb)
self.wifi_bridge_ip = QLineEdit("192.168.60.1")
self.wifi_dhcp_start = QLineEdit("192.168.60.10")
self.wifi_dhcp_end = QLineEdit("192.168.60.100")
wifi_dhcp_layout.addRow("Bridge IP:", self.wifi_bridge_ip)
wifi_dhcp_layout.addRow("DHCP Start:", self.wifi_dhcp_start)
wifi_dhcp_layout.addRow("DHCP End:", self.wifi_dhcp_end)
wifi_layout.addWidget(wifi_dhcp_group)
self.wifi_dhcp_group = wifi_dhcp_group
# Initialize Wi-Fi DHCP inputs state
self.toggle_wifi_dhcp_inputs(False)
wifi_btns_layout = QHBoxLayout()
self.wifi_btn = QPushButton("Start Wi-Fi AP")
self.wifi_btn.clicked.connect(self.start_wifi)
wifi_btns_layout.addWidget(self.wifi_btn)
self.stop_wifi_btn = QPushButton("Stop Wi-Fi AP")
self.stop_wifi_btn.clicked.connect(self.stop_wifi)
self.stop_wifi_btn.setEnabled(False)
wifi_btns_layout.addWidget(self.stop_wifi_btn)
wifi_layout.addLayout(wifi_btns_layout)
# Connected Clients
self.clients_group = QGroupBox("Connected Clients")
clients_layout = QVBoxLayout(self.clients_group)
clients_layout.setContentsMargins(5, 15, 5, 5)
self.clients_table = QTableWidget()
self.clients_table.setColumnCount(3)
self.clients_table.setHorizontalHeaderLabels(["MAC", "IP", "Hostname"])
self.clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.clients_table.setMaximumHeight(150)
clients_layout.addWidget(self.clients_table)
wifi_layout.addWidget(self.clients_group)
right_col.addWidget(wifi_group)
top_layout.addLayout(right_col)
# Client Refresh Timer
self.client_timer = QTimer()
self.client_timer.timeout.connect(self.refresh_clients)
self.client_timer.setInterval(5000) # 5 seconds
self.splitter.addWidget(top_widget)
# Bottom Part: VLAN Scanner
scanner_group = QGroupBox("Passive VLAN Scanner")
scanner_layout = QVBoxLayout(scanner_group)
scanner_layout.setContentsMargins(10, 15, 10, 10)
scan_controls = QHBoxLayout()
self.scan_iface_combo = QComboBox()
self.scan_btn = QPushButton("Start Scan (10s)")
self.scan_btn.clicked.connect(self.start_scan)
scan_controls.addWidget(QLabel("Interface:"))
scan_controls.addWidget(self.scan_iface_combo)
scan_controls.addWidget(self.scan_btn)
scanner_layout.addLayout(scan_controls)
self.scan_table = QTableWidget()
self.scan_table.setColumnCount(2)
self.scan_table.setHorizontalHeaderLabels(["VLAN ID", "Packets Detected"])
self.scan_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.scan_table.setMinimumHeight(200) # Ensure it has space
scanner_layout.addWidget(self.scan_table)
self.port_type_label = QLabel("Port Type: Unknown")
self.port_type_label.setStyleSheet("font-weight: bold; color: #aaaaaa;")
scanner_layout.addWidget(self.port_type_label)
self.splitter.addWidget(scanner_group)
# Set initial sizes for splitter (Top, Bottom)
self.splitter.setSizes([400, 300])
self.refresh_interfaces()
self.toggle_mode_inputs() # Initialize UI based on default mode
def refresh_interfaces(self):
interfaces = self.nm.list_interfaces()
self.trunk_combo.clear()
self.access_combo.clear()
self.scan_iface_combo.clear()
self.wifi_iface_combo.clear()
for iface in interfaces:
name = iface['name']
self.trunk_combo.addItem(name)
self.access_combo.addItem(name)
self.scan_iface_combo.addItem(name)
if iface['type'] == 'Wireless':
self.wifi_iface_combo.addItem(name)
# Populate bridges
self.bridge_select_combo.clear()
self.active_bridges_combo.clear()
bridges = [iface['name'] for iface in interfaces if iface['name'].startswith('br')]
for br in bridges:
self.bridge_select_combo.addItem(br)
self.active_bridges_combo.addItem(br)
# Add manual entry option for Wi-Fi bridge selection if needed
self.bridge_select_combo.setEditable(True)
def toggle_mode_inputs(self):
mode = self.mode_combo.currentText()
if "Passthrough" in mode:
self.lbl_input.setText("Interface 1:")
self.lbl_output.setText("Interface 2:")
self.lbl_vlan.setVisible(False)
self.vlan_input.setVisible(False)
else:
self.lbl_input.setText("Input Interface (Trunk):")
self.lbl_output.setText("Output Interface (Access):")
self.lbl_vlan.setVisible(True)
self.lbl_vlan.setVisible(True)
self.vlan_input.setVisible(True)
def toggle_dhcp_inputs(self, checked):
self.bridge_ip.setEnabled(checked)
self.dhcp_start.setEnabled(checked)
self.dhcp_end.setEnabled(checked)
def toggle_wifi_dhcp_inputs(self, checked):
self.wifi_bridge_ip.setEnabled(checked)
self.wifi_dhcp_start.setEnabled(checked)
self.wifi_dhcp_end.setEnabled(checked)
def on_trunk_combo_changed(self, index):
iface_name = self.trunk_combo.currentText()
self.autofill_dhcp(iface_name, self.bridge_ip, self.dhcp_start, self.dhcp_end)
def on_wifi_iface_changed(self, index):
iface_name = self.wifi_iface_combo.currentText()
self.autofill_dhcp(iface_name, self.wifi_bridge_ip, self.wifi_dhcp_start, self.wifi_dhcp_end)
def autofill_dhcp(self, iface_name, ip_field, start_field, end_field):
if not iface_name:
return
# Get config to auto-fill DHCP
config = self.nm.get_interface_config(iface_name)
ip = config.get('ip', '')
if ip:
# Suggest a subnet based on this IP
parts = ip.split('.')
if len(parts) == 4:
base = ".".join(parts[:3])
ip_field.setText(ip)
start_field.setText(f"{base}.100")
end_field.setText(f"{base}.200")
def create_bridge(self):
mode = self.mode_combo.currentText()
iface1 = self.trunk_combo.currentText()
iface2 = self.access_combo.currentText()
if iface1 == iface2:
QMessageBox.warning(self, "Error", "Interfaces must be different")
return
success = False
msg = ""
bridge_name = ""
if "Passthrough" in mode:
success, msg = self.nm.create_simple_bridge(iface1, iface2)
bridge_name = "br_pass"
else:
vlan_id = self.vlan_input.value()
success, msg = self.nm.create_vlan_bridge(iface1, vlan_id, iface2)
bridge_name = f"br{vlan_id}"
if success:
# Handle IP/DHCP
if self.enable_dhcp_cb.isChecked():
ip = self.bridge_ip.text()
start = self.dhcp_start.text()
end = self.dhcp_end.text()
# Start DHCP
d_success, d_msg = self.nm.start_dhcp_server(bridge_name, ip, start, end)
if d_success:
msg += f"\nDHCP Server running on {ip}"
else:
msg += f"\nDHCP Error: {d_msg}"
QMessageBox.information(self, "Success", msg)
self.bridge_select_combo.addItem(bridge_name)
else:
QMessageBox.critical(self, "Error", msg)
def start_wifi(self):
wifi_iface = self.wifi_iface_combo.currentText()
ssid = self.ssid_input.text()
password = self.password_input.text()
bridge = self.bridge_select_combo.currentText()
if not wifi_iface or not ssid or not password or not bridge:
QMessageBox.warning(self, "Error", "Please fill all Wi-Fi fields.")
return
success, msg = self.nm.setup_wifi_ap(wifi_iface, ssid, password, bridge)
if success:
# Handle DHCP for Wi-Fi
if self.wifi_enable_dhcp_cb.isChecked():
ip = self.wifi_bridge_ip.text()
start = self.wifi_dhcp_start.text()
end = self.wifi_dhcp_end.text()
# Start DHCP on the bridge
d_success, d_msg = self.nm.start_dhcp_server(bridge, ip, start, end)
if d_success:
msg += f"\nDHCP Server running on {ip}"
else:
msg += f"\nDHCP Error: {d_msg}"
self.wifi_btn.setEnabled(False)
self.stop_wifi_btn.setEnabled(True)
self.client_timer.start()
QMessageBox.information(self, "Success", msg)
else:
QMessageBox.critical(self, "Error", f"Failed to start Wi-Fi AP: {msg}")
def delete_bridge(self):
bridge = self.active_bridges_combo.currentText()
if not bridge:
return
confirm = QMessageBox.question(self, "Confirm Deletion",
f"Are you sure you want to delete bridge '{bridge}'?\nThis will stop any associated DHCP servers.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if confirm == QMessageBox.StandardButton.Yes:
success, msg = self.nm.delete_bridge(bridge)
if success:
QMessageBox.information(self, "Success", msg)
self.refresh_interfaces()
else:
QMessageBox.critical(self, "Error", msg)
def stop_wifi(self):
iface = self.wifi_iface_combo.currentText()
success, msg = self.nm.stop_wifi_ap(iface)
if success:
self.wifi_btn.setEnabled(True)
self.stop_wifi_btn.setEnabled(False)
self.client_timer.stop()
self.clients_table.setRowCount(0)
QMessageBox.information(self, "Success", msg)
else:
QMessageBox.critical(self, "Error", msg)
def refresh_clients(self):
iface = self.wifi_iface_combo.currentText()
clients = self.nm.get_wifi_clients(iface)
self.clients_table.setRowCount(len(clients))
for i, client in enumerate(clients):
self.clients_table.setItem(i, 0, QTableWidgetItem(client['mac']))
self.clients_table.setItem(i, 1, QTableWidgetItem(client['ip']))
self.clients_table.setItem(i, 2, QTableWidgetItem(client['hostname']))
def start_scan(self):
if self.scan_btn.text() == "Stop Scan":
if self.scan_thread:
self.scan_thread.stop()
self.scan_btn.setEnabled(False)
self.scan_btn.setText("Stopping...")
return
iface = self.scan_iface_combo.currentText()
self.scan_table.setRowCount(0)
self.port_type_label.setText("Port Type: Scanning...")
self.port_type_label.setStyleSheet("font-weight: bold; color: #e0e000;")
self.scan_btn.setText("Stop Scan")
self.scan_btn.setStyleSheet("background-color: #800000; color: white;")
self.scan_thread = self.vlan_scanner.scan(iface)
self.scan_thread.vlan_detected.connect(self.on_vlan_detected)
self.scan_thread.untagged_detected.connect(self.on_untagged_detected)
self.scan_thread.scan_finished.connect(self.on_scan_finished)
self.scan_thread.error_occurred.connect(self.on_scan_error)
def on_untagged_detected(self, count):
self.update_table_row("Untagged (Native)", count)
self.update_port_analysis()
def on_vlan_detected(self, vlan_id, count):
self.update_table_row(str(vlan_id), count)
self.update_port_analysis()
def update_table_row(self, label, count):
# Check if row exists
found = False
for row in range(self.scan_table.rowCount()):
if self.scan_table.item(row, 0).text() == label:
self.scan_table.setItem(row, 1, QTableWidgetItem(str(count)))
found = True
break
if not found:
row = self.scan_table.rowCount()
self.scan_table.insertRow(row)
self.scan_table.setItem(row, 0, QTableWidgetItem(label))
self.scan_table.setItem(row, 1, QTableWidgetItem(str(count)))
def update_port_analysis(self):
# Analyze table content to guess port type
has_untagged = False
vlan_count = 0
for row in range(self.scan_table.rowCount()):
label = self.scan_table.item(row, 0).text()
if label == "Untagged (Native)":
has_untagged = True
else:
vlan_count += 1
if vlan_count > 1:
msg = f"Trunk Port (Detected {vlan_count} VLANs)"
color = "#00e000" # Green
elif vlan_count == 1 and has_untagged:
msg = "Hybrid Port (Tagged + Untagged)"
color = "#e0e000" # Yellow
elif vlan_count == 1:
msg = "Trunk Port (Single VLAN)"
color = "#00e000"
elif has_untagged:
msg = "Access Port (Untagged Traffic Only)"
color = "#00e0e0" # Cyan
else:
msg = "Unknown / No Traffic"
color = "#aaaaaa"
self.port_type_label.setText(f"Port Type: {msg}")
self.port_type_label.setStyleSheet(f"font-weight: bold; color: {color};")
def on_scan_finished(self):
self.scan_btn.setEnabled(True)
self.scan_btn.setText("Start Scan (10s)")
self.scan_btn.setStyleSheet("")
# Don't show popup if just stopped, but hard to distinguish here without flag.
# Let's just show it, or maybe check if it was manual stop?
# For now, simple message is fine.
# QMessageBox.information(self, "Scan Finished", "VLAN Scan completed.")
def on_scan_error(self, msg):
self.scan_btn.setEnabled(True)
self.scan_btn.setText("Start Scan (10s)")
self.scan_btn.setStyleSheet("")
QMessageBox.critical(self, "Error", msg)
def closeEvent(self, event):
if self.scan_thread and self.scan_thread.isRunning():
self.scan_thread.stop()
event.accept()
+157
View File
@@ -0,0 +1,157 @@
"""
Widget de detección de VLANs en un trunk (Scapy).
Funciona en Windows (con Npcap) y en Linux.
"""
from PyQt6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QComboBox,
QPushButton,
QGroupBox,
QTableWidget,
QTableWidgetItem,
QHeaderView,
QMessageBox,
)
from PyQt6.QtCore import Qt
from src.core.vlan_scanner import VLANScanner
class VLANDetectionWidget(QWidget):
"""Solo detección pasiva de VLANs (Scapy). Sin bridge ni tcpdump."""
def __init__(self, network_manager):
super().__init__()
self.nm = network_manager
self.vlan_scanner = VLANScanner()
self.scan_thread = None
layout = QVBoxLayout(self)
group = QGroupBox("Detección de VLANs en trunk")
group_layout = QVBoxLayout(group)
controls = QHBoxLayout()
controls.addWidget(QLabel("Interfaz:"))
self.iface_combo = QComboBox()
controls.addWidget(self.iface_combo)
self.scan_btn = QPushButton("Iniciar escaneo (10 s)")
self.scan_btn.clicked.connect(self.start_scan)
controls.addWidget(self.scan_btn)
group_layout.addLayout(controls)
self.scan_table = QTableWidget()
self.scan_table.setColumnCount(2)
self.scan_table.setHorizontalHeaderLabels(["VLAN ID", "Paquetes detectados"])
self.scan_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.scan_table.setMinimumHeight(200)
group_layout.addWidget(self.scan_table)
self.port_type_label = QLabel("Tipo de puerto: —")
self.port_type_label.setStyleSheet("font-weight: bold; color: #aaaaaa;")
group_layout.addWidget(self.port_type_label)
layout.addWidget(group)
info = QLabel(
"Conecta esta interfaz a un puerto trunk y pulsa Iniciar escaneo. "
"En Windows requiere Npcap y ejecutar como Administrador."
)
info.setWordWrap(True)
info.setStyleSheet("color: #888; font-size: 11px;")
layout.addWidget(info)
self.refresh_interfaces()
def refresh_interfaces(self):
self.iface_combo.clear()
for iface in self.nm.list_interfaces():
self.iface_combo.addItem(iface["name"])
def start_scan(self):
if self.scan_thread and self.scan_thread.isRunning():
self.scan_thread.stop()
self.scan_btn.setEnabled(False)
self.scan_btn.setText("Deteniendo...")
return
iface = self.iface_combo.currentText()
if not iface:
QMessageBox.warning(self, "Error", "Selecciona una interfaz.")
return
self.scan_table.setRowCount(0)
self.port_type_label.setText("Tipo de puerto: Escaneando...")
self.port_type_label.setStyleSheet("font-weight: bold; color: #e0e000;")
self.scan_btn.setText("Detener escaneo")
self.scan_btn.setStyleSheet("background-color: #800000; color: white;")
self.scan_thread = self.vlan_scanner.scan_scapy(iface, timeout=10)
self.scan_thread.vlan_detected.connect(self.on_vlan_detected)
self.scan_thread.untagged_detected.connect(self.on_untagged_detected)
self.scan_thread.scan_finished.connect(self.on_scan_finished)
self.scan_thread.error_occurred.connect(self.on_scan_error)
def on_untagged_detected(self, count):
self._update_row("Untagged (Native)", count)
self._update_port_analysis()
def on_vlan_detected(self, vlan_id, count):
self._update_row(str(vlan_id), count)
self._update_port_analysis()
def _update_row(self, label, count):
for row in range(self.scan_table.rowCount()):
if self.scan_table.item(row, 0).text() == label:
self.scan_table.setItem(row, 1, QTableWidgetItem(str(count)))
return
row = self.scan_table.rowCount()
self.scan_table.insertRow(row)
self.scan_table.setItem(row, 0, QTableWidgetItem(label))
self.scan_table.setItem(row, 1, QTableWidgetItem(str(count)))
def _update_port_analysis(self):
has_untagged = False
vlan_count = 0
for row in range(self.scan_table.rowCount()):
label = self.scan_table.item(row, 0).text()
if label == "Untagged (Native)":
has_untagged = True
else:
vlan_count += 1
if vlan_count > 1:
msg = f"Trunk ({vlan_count} VLANs)"
color = "#00e000"
elif vlan_count == 1 and has_untagged:
msg = "Híbrido (tagged + untagged)"
color = "#e0e000"
elif vlan_count == 1:
msg = "Trunk (1 VLAN)"
color = "#00e000"
elif has_untagged:
msg = "Access (solo untagged)"
color = "#00e0e0"
else:
msg = "— / Sin tráfico"
color = "#aaaaaa"
self.port_type_label.setText(f"Tipo de puerto: {msg}")
self.port_type_label.setStyleSheet(f"font-weight: bold; color: {color};")
def on_scan_finished(self):
self.scan_btn.setEnabled(True)
self.scan_btn.setText("Iniciar escaneo (10 s)")
self.scan_btn.setStyleSheet("")
def on_scan_error(self, msg):
self.scan_btn.setEnabled(True)
self.scan_btn.setText("Iniciar escaneo (10 s)")
self.scan_btn.setStyleSheet("")
QMessageBox.critical(self, "Error", msg)
def closeEvent(self, event):
if self.scan_thread and self.scan_thread.isRunning():
self.scan_thread.stop()
event.accept()
+40
View File
@@ -0,0 +1,40 @@
"""
Ruta al ejecutable nmap. En Windows puede estar en la carpeta del instalador (bundled).
"""
import os
import sys
import shutil
from src.utils.platform import is_windows
def get_nmap_command() -> list:
"""
Devuelve la lista de argumentos para ejecutar nmap (ej. ["nmap"] o ["C:\\...\\nmap.exe"]).
En Windows busca nmap.exe junto al ejecutable o en PATH.
"""
if not is_windows():
return ["nmap"]
# Si estamos empaquetados con PyInstaller, sys.executable es el .exe
if getattr(sys, "frozen", False):
base_dir = os.path.dirname(sys.executable)
# Instalador puede dejar nmap en {app}\nmap\nmap.exe o {app}\nmap-7.94\nmap.exe
for sub in ["nmap", "nmap-7.94", "nmap-7.93"]:
candidate = os.path.join(base_dir, sub, "nmap.exe")
if os.path.isfile(candidate):
return [candidate]
try:
for name in os.listdir(base_dir):
if name.startswith("nmap-") and os.path.isdir(os.path.join(base_dir, name)):
candidate = os.path.join(base_dir, name, "nmap.exe")
if os.path.isfile(candidate):
return [candidate]
except OSError:
pass
# Nmap en PATH o en carpeta del script
nmap_exe = shutil.which("nmap") or shutil.which("nmap.exe")
if nmap_exe:
return [nmap_exe]
return ["nmap"] # fallback: que subprocess lo busque en PATH
+10
View File
@@ -0,0 +1,10 @@
"""
Detección de plataforma para funcionalidades compatibles con Windows/Linux.
"""
import sys
def is_windows() -> bool:
return sys.platform == "win32"
def is_linux() -> bool:
return sys.platform.startswith("linux")
+104
View File
@@ -0,0 +1,104 @@
import datetime
import os
class ReportGenerator:
def __init__(self):
pass
def generate_html_report(self, interfaces, scan_results, sniff_all, sniff_failed, filename="report.html"):
"""
Generates an HTML report with the provided data.
"""
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>JNC-NetTools Report</title>
<style>
body {{ font-family: 'Segoe UI', sans-serif; background-color: #f4f4f4; color: #333; margin: 0; padding: 20px; }}
.container {{ max-width: 1000px; margin: 0 auto; background: white; padding: 30px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); border-radius: 8px; }}
h1 {{ color: #0078d7; border-bottom: 3px solid #0078d7; padding-bottom: 10px; margin-bottom: 30px; }}
h2 {{ color: #444; margin-top: 40px; border-left: 5px solid #0078d7; padding-left: 10px; }}
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: #f8f9fa; color: #333; font-weight: 600; }}
tr:nth-child(even) {{ background-color: #fcfcfc; }}
.timestamp {{ color: #666; font-size: 0.9em; margin-bottom: 20px; }}
.status-up {{ color: green; font-weight: bold; }}
.status-down {{ color: red; font-weight: bold; }}
.error-row {{ background-color: #fff0f0 !important; }}
.error-text {{ color: #d32f2f; font-weight: bold; }}
.badge {{ padding: 3px 8px; border-radius: 12px; font-size: 0.85em; color: white; }}
.badge-tcp {{ background-color: #0078d7; }}
.badge-udp {{ background-color: #e65100; }}
</style>
</head>
<body>
<div class="container">
<h1>JNC-NetTools Diagnostic Report</h1>
<p class="timestamp">Generated on: {datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
<h2>Network Interfaces</h2>
<table>
<tr><th>Name</th><th>Type</th><th>Status</th><th>IP Address</th><th>MAC Address</th></tr>
"""
for iface in interfaces:
ip = iface['ipv4'][0] if iface['ipv4'] else "N/A"
status_class = "status-up" if iface['is_up'] else "status-down"
html += f"<tr><td><b>{iface['name']}</b></td><td>{iface['type']}</td><td class='{status_class}'>{iface['status']}</td><td>{ip}</td><td>{iface['mac']}</td></tr>"
html += """
</table>
<h2>Port Scan Results</h2>
<table>
<tr><th>IP</th><th>MAC</th><th>Vendor</th><th>Status</th></tr>
"""
if not scan_results:
html += "<tr><td colspan='4' style='text-align:center; color:#888;'>No scan results available.</td></tr>"
else:
for item in scan_results:
html += f"<tr><td>{item['ip']}</td><td>{item['mac']}</td><td>{item['vendor']}</td><td>{item['status']}</td></tr>"
html += """
</table>
<h2>Anomalies / Failed Traffic (Errors & Retransmissions)</h2>
<table>
<tr><th>Time</th><th>Source</th><th>Destination</th><th>Protocol</th><th>Info</th><th>Status</th></tr>
"""
if not sniff_failed:
html += "<tr><td colspan='6' style='text-align:center; color:#888;'>No anomalies detected.</td></tr>"
else:
for pkt in sniff_failed:
html += f"<tr class='error-row'><td>{pkt['time']}</td><td>{pkt['src']}</td><td>{pkt['dst']}</td><td>{pkt['proto']}</td><td>{pkt['info']}</td><td class='error-text'>{pkt['status']}</td></tr>"
html += """
</table>
<h2>Traffic Log (Last 100 Packets)</h2>
<table>
<tr><th>Time</th><th>Source</th><th>Destination</th><th>Protocol</th><th>Info</th><th>Status</th></tr>
"""
# Limit to last 100 for readability
for pkt in sniff_all[-100:]:
html += f"<tr><td>{pkt['time']}</td><td>{pkt['src']}</td><td>{pkt['dst']}</td><td>{pkt['proto']}</td><td>{pkt['info']}</td><td>{pkt['status']}</td></tr>"
html += """
</table>
</div>
</body>
</html>
"""
try:
with open(filename, "w") as f:
f.write(html)
return True, os.path.abspath(filename)
except Exception as e:
return False, str(e)
+86
View File
@@ -0,0 +1,86 @@
# JNC-Scan Walkthrough
This guide explains how to run and verify the **JNC-Scan** network diagnostic tool.
## Prerequisites
Ensure you have the required system dependencies:
```bash
sudo apt install python3-pyqt6 nmap tcpdump bridge-utils hostapd dnsmasq python3-venv libxcb-cursor0 ethtool
```
## Installation (Virtual Environment)
Since modern Linux distributions manage Python packages externally, we should use a virtual environment:
1. **Create a virtual environment**:
```bash
python3 -m venv venv
```
2. **Activate it and install dependencies**:
```bash
source venv/bin/activate
pip install -r requirements.txt
```
## Running the Application
**CRITICAL**: The application requires **root privileges**. When using a virtual environment, point `sudo` to the venv's Python executable:
```bash
sudo ./venv/bin/python3 main.py
```
## Verification Steps
### 1. Interface Management
- **Launch**: Verify the app starts and lists your network interfaces.
- **Details**: Select an interface to see its IP, MAC, and status.
### 2. IP Configuration & Calculator
- **Static IP**: Go to "IP Configuration". Select an interface, choose "Static", enter an IP (e.g., `192.168.99.1`) and CIDR (`24`). Click "Apply". Verify with `ip addr` in a terminal.
- **Calculator**: Enter an IP and CIDR in the calculator section. Click "Calculate" to see the Netmask and Broadcast address.
### 3. VLAN & Bridging
- **Wired Bridge**:
1. Go to "VLAN / Bridge".
2. Select your main Ethernet (Trunk) and a secondary Ethernet (Access).
3. Enter a VLAN ID (e.g., `35`).
4. Click "Create Bridge".
5. Verify connectivity on the secondary interface.
- **Wi-Fi AP**:
1. Select a Wireless interface.
2. Enter SSID and Password.
3. Select the bridge you just created (e.g., `br35`).
4. Click "Start Wi-Fi AP".
- **VLAN Scanner**:
1. In "VLAN / Bridge", look for "Passive VLAN Scanner".
2. Select an interface.
3. Click "Start Scan".
4. Wait 10 seconds. The table will show any VLAN tags detected on the wire.
### 4. Network Scanner
- Go to "Scanner".
- Enter a range (e.g., `192.168.1.0/24`).
- Click "Scan Network".
- Verify the list of devices appears.
### 5. Packet Sniffer
- Go to "Sniffer".
- Select an interface (e.g., the bridge `br35`).
- Click "Start Sniffing".
- Generate traffic (ping, browse).
- Verify packets appear in the table.
### 6. Reporting
- Click "Generate Report" in the toolbar.
- Save the HTML file.
- Open it in a browser to view the summary of interfaces, scan results, and captured packets.
## Troubleshooting
- **"No module named..."**: Ensure you are running inside the venv: `sudo ./venv/bin/python3 main.py`.
- **"xcb plugin error"**: Install `libxcb-cursor0`.
- **"Permission denied"**: Ensure you are running with `sudo`.
- **NetworkManager errors**: Ensure `nmcli` is installed (`sudo apt install network-manager`).
+233
View File
@@ -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>