JNC Windows
This commit is contained in:
+22
@@ -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
|
||||||
@@ -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 ""
|
||||||
@@ -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 ""
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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.
@@ -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
@@ -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
|
||||||
Vendored
BIN
Binary file not shown.
Binary file not shown.
@@ -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).
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Dependencias para Windows (sin pyroute2)
|
||||||
|
PyQt6
|
||||||
|
scapy
|
||||||
|
python-nmap
|
||||||
|
psutil
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
PyQt6
|
||||||
|
pyroute2
|
||||||
|
scapy
|
||||||
|
python-nmap
|
||||||
|
netifaces
|
||||||
|
psutil
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,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)
|
||||||
|
|
||||||
@@ -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)}"
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
@@ -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}")
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
""")
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,765 @@
|
|||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QTabWidget,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QHeaderView,
|
||||||
|
QGroupBox,
|
||||||
|
QFormLayout,
|
||||||
|
QComboBox,
|
||||||
|
QCheckBox,
|
||||||
|
QSpinBox,
|
||||||
|
QMessageBox,
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
from src.core.advanced_router import AdvancedRouter
|
||||||
|
from src.core.network_manager import NetworkManager
|
||||||
|
|
||||||
|
|
||||||
|
class AdvancedRoutingWidget(QWidget):
|
||||||
|
"""
|
||||||
|
Pestaña principal de \"Routing Avanzado\".
|
||||||
|
Organiza la funcionalidad en subpestañas internas:
|
||||||
|
- VLANs
|
||||||
|
- Redirección
|
||||||
|
- Redes (gateway/DHCP)
|
||||||
|
- Routing
|
||||||
|
- NAT
|
||||||
|
- Firewall
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, network_manager: NetworkManager, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.nm = network_manager
|
||||||
|
self.router = AdvancedRouter(self.nm)
|
||||||
|
|
||||||
|
main_layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
title = QLabel("Routing Avanzado")
|
||||||
|
title.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
title.setStyleSheet("font-size: 18px; font-weight: 600;")
|
||||||
|
main_layout.addWidget(title)
|
||||||
|
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
main_layout.addWidget(self.tabs)
|
||||||
|
|
||||||
|
# Subpestañas
|
||||||
|
self._init_vlans_tab()
|
||||||
|
self._init_redirection_tab()
|
||||||
|
self._init_networks_tab()
|
||||||
|
self._init_routing_tab()
|
||||||
|
self._init_nat_tab()
|
||||||
|
self._init_firewall_tab()
|
||||||
|
|
||||||
|
self.refresh_interfaces()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# VLANs
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _init_vlans_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
# Formulario creación VLANs
|
||||||
|
form_group = QGroupBox("Gestión de VLANs")
|
||||||
|
form_layout = QFormLayout(form_group)
|
||||||
|
|
||||||
|
self.vlan_mode_combo = QComboBox()
|
||||||
|
self.vlan_mode_combo.addItems(
|
||||||
|
["Trunk (Tagged)", "Access (Untagged)"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.vlan_iface_combo = QComboBox()
|
||||||
|
self.vlan_ids_input = QLineEdit()
|
||||||
|
self.vlan_ids_input.setPlaceholderText("Ej: 10,20,30 o 100-120")
|
||||||
|
|
||||||
|
create_btn = QPushButton("Crear VLAN(es)")
|
||||||
|
create_btn.clicked.connect(self.on_create_vlans_clicked)
|
||||||
|
|
||||||
|
form_layout.addRow("Modo:", self.vlan_mode_combo)
|
||||||
|
form_layout.addRow("Interfaz:", self.vlan_iface_combo)
|
||||||
|
form_layout.addRow("VLAN ID(s):", self.vlan_ids_input)
|
||||||
|
form_layout.addRow("", create_btn)
|
||||||
|
|
||||||
|
layout.addWidget(form_group)
|
||||||
|
|
||||||
|
# Tabla resumen VLANs
|
||||||
|
self.vlan_table = QTableWidget()
|
||||||
|
self.vlan_table.setColumnCount(5)
|
||||||
|
self.vlan_table.setHorizontalHeaderLabels(
|
||||||
|
["Interface", "VLAN ID", "Modo", "Detalle", "Acción"]
|
||||||
|
)
|
||||||
|
self.vlan_table.horizontalHeader().setSectionResizeMode(
|
||||||
|
QHeaderView.ResizeMode.Stretch
|
||||||
|
)
|
||||||
|
layout.addWidget(self.vlan_table)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "VLANs")
|
||||||
|
|
||||||
|
def parse_vlan_ids(self, text: str):
|
||||||
|
ids = set()
|
||||||
|
for part in text.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if "-" in part:
|
||||||
|
start, end = part.split("-", 1)
|
||||||
|
try:
|
||||||
|
s = int(start)
|
||||||
|
e = int(end)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
for vid in range(s, e + 1):
|
||||||
|
ids.add(vid)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
ids.add(int(part))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return sorted(ids)
|
||||||
|
|
||||||
|
def on_create_vlans_clicked(self):
|
||||||
|
iface = self.vlan_iface_combo.currentText()
|
||||||
|
vlan_text = self.vlan_ids_input.text().strip()
|
||||||
|
if not iface or not vlan_text:
|
||||||
|
QMessageBox.warning(self, "Error", "Selecciona interfaz y VLAN ID(s).")
|
||||||
|
return
|
||||||
|
|
||||||
|
vlan_ids = self.parse_vlan_ids(vlan_text)
|
||||||
|
if not vlan_ids:
|
||||||
|
QMessageBox.warning(self, "Error", "Formato de VLAN ID(s) no válido.")
|
||||||
|
return
|
||||||
|
|
||||||
|
mode = self.vlan_mode_combo.currentText()
|
||||||
|
if "Trunk" in mode:
|
||||||
|
ok, msg = self.router.create_vlan_trunk(iface, vlan_ids)
|
||||||
|
else:
|
||||||
|
if len(vlan_ids) != 1:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Error",
|
||||||
|
"En modo Access sólo se permite un único VLAN ID.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
ok, msg = self.router.create_vlan_access(iface, vlan_ids[0])
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
QMessageBox.information(self, "VLAN", msg)
|
||||||
|
self.refresh_vlan_table()
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(self, "Error VLAN", msg)
|
||||||
|
|
||||||
|
def refresh_vlan_table(self):
|
||||||
|
status = self.router.get_routing_status()
|
||||||
|
vlans = status.get("vlans", {})
|
||||||
|
self.vlan_table.setRowCount(len(vlans))
|
||||||
|
for row, (ifname, data) in enumerate(vlans.items()):
|
||||||
|
self.vlan_table.setItem(row, 0, QTableWidgetItem(ifname))
|
||||||
|
self.vlan_table.setItem(
|
||||||
|
row, 1, QTableWidgetItem(str(data.get("vlan_id", "")))
|
||||||
|
)
|
||||||
|
self.vlan_table.setItem(row, 2, QTableWidgetItem(data.get("mode", "")))
|
||||||
|
self.vlan_table.setItem(
|
||||||
|
row,
|
||||||
|
3,
|
||||||
|
QTableWidgetItem(f"parent={data.get('parent', '')}"),
|
||||||
|
)
|
||||||
|
# Botón eliminar
|
||||||
|
delete_btn = QPushButton("Eliminar")
|
||||||
|
delete_btn.setStyleSheet("background-color: #800000; color: white; padding: 4px 8px;")
|
||||||
|
delete_btn.clicked.connect(lambda checked, vlan_name=ifname: self.on_delete_vlan_clicked(vlan_name))
|
||||||
|
self.vlan_table.setCellWidget(row, 4, delete_btn)
|
||||||
|
|
||||||
|
def on_delete_vlan_clicked(self, vlan_ifname: str):
|
||||||
|
"""Elimina una VLAN tras confirmación del usuario."""
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Confirmar eliminación",
|
||||||
|
f"¿Estás seguro de que quieres eliminar la VLAN '{vlan_ifname}'?\n\n"
|
||||||
|
"Esto eliminará la interfaz VLAN y limpiará todas las configuraciones asociadas.",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No
|
||||||
|
)
|
||||||
|
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
ok, msg = self.router.delete_vlan(vlan_ifname)
|
||||||
|
if ok:
|
||||||
|
QMessageBox.information(self, "VLAN eliminada", msg)
|
||||||
|
self.refresh_vlan_table()
|
||||||
|
# También refrescamos otras tablas que puedan verse afectadas
|
||||||
|
self.refresh_redirection_table()
|
||||||
|
self.refresh_networks_table()
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(self, "Error al eliminar VLAN", msg)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Redirección de VLANs
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _init_redirection_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
form_group = QGroupBox("Redirección de VLANs")
|
||||||
|
form_layout = QFormLayout(form_group)
|
||||||
|
|
||||||
|
self.src_iface_combo = QComboBox()
|
||||||
|
self.src_vlan_spin = QSpinBox()
|
||||||
|
self.src_vlan_spin.setRange(1, 4094)
|
||||||
|
self.src_vlan_spin.setSpecialValueText("Untagged")
|
||||||
|
|
||||||
|
self.dst_iface_combo = QComboBox()
|
||||||
|
self.dst_vlan_spin = QSpinBox()
|
||||||
|
self.dst_vlan_spin.setRange(1, 4094)
|
||||||
|
self.dst_vlan_spin.setSpecialValueText("Untagged")
|
||||||
|
|
||||||
|
self.tagged_to_untagged_cb = QCheckBox("Forzar Tagged → Untagged")
|
||||||
|
|
||||||
|
redirect_btn = QPushButton("Crear redirección")
|
||||||
|
redirect_btn.clicked.connect(self.on_create_redirection_clicked)
|
||||||
|
|
||||||
|
form_layout.addRow("Interfaz origen:", self.src_iface_combo)
|
||||||
|
form_layout.addRow("VLAN origen (0 = untagged):", self.src_vlan_spin)
|
||||||
|
form_layout.addRow("Interfaz destino:", self.dst_iface_combo)
|
||||||
|
form_layout.addRow("VLAN destino (0 = untagged):", self.dst_vlan_spin)
|
||||||
|
form_layout.addRow("", self.tagged_to_untagged_cb)
|
||||||
|
form_layout.addRow("", redirect_btn)
|
||||||
|
|
||||||
|
layout.addWidget(form_group)
|
||||||
|
|
||||||
|
self.redir_table = QTableWidget()
|
||||||
|
self.redir_table.setColumnCount(5)
|
||||||
|
self.redir_table.setHorizontalHeaderLabels(
|
||||||
|
["Origen", "VLAN Origen", "Destino", "VLAN Destino", "Bridge"]
|
||||||
|
)
|
||||||
|
self.redir_table.horizontalHeader().setSectionResizeMode(
|
||||||
|
QHeaderView.ResizeMode.Stretch
|
||||||
|
)
|
||||||
|
layout.addWidget(self.redir_table)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "Redirección")
|
||||||
|
|
||||||
|
def on_create_redirection_clicked(self):
|
||||||
|
src_iface = self.src_iface_combo.currentText()
|
||||||
|
dst_iface = self.dst_iface_combo.currentText()
|
||||||
|
src_vlan = self.src_vlan_spin.value() or None
|
||||||
|
dst_vlan = self.dst_vlan_spin.value() or None
|
||||||
|
|
||||||
|
if not src_iface or not dst_iface:
|
||||||
|
QMessageBox.warning(self, "Error", "Selecciona interfaces de origen y destino.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.tagged_to_untagged_cb.isChecked():
|
||||||
|
# Forzamos patrón trunk -> access
|
||||||
|
if src_vlan is None:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Error",
|
||||||
|
"Para Tagged → Untagged la VLAN de origen no puede ser 0.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
ok, msg = self.router.redirect_vlan_tagged_to_untagged(
|
||||||
|
trunk_iface=src_iface, vlan_id=src_vlan, access_iface=dst_iface
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ok, msg = self.router.redirect_vlan_between_interfaces(
|
||||||
|
src_iface=src_iface,
|
||||||
|
src_vlan_id=src_vlan,
|
||||||
|
dst_iface=dst_iface,
|
||||||
|
dst_vlan_id=dst_vlan,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
QMessageBox.information(self, "Redirección", msg)
|
||||||
|
self.refresh_redirection_table()
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(self, "Error redirección", msg)
|
||||||
|
|
||||||
|
def refresh_redirection_table(self):
|
||||||
|
status = self.router.get_routing_status()
|
||||||
|
rules = status.get("vlan_redirections", [])
|
||||||
|
self.redir_table.setRowCount(len(rules))
|
||||||
|
for row, r in enumerate(rules):
|
||||||
|
self.redir_table.setItem(row, 0, QTableWidgetItem(r.get("src_iface", "")))
|
||||||
|
self.redir_table.setItem(
|
||||||
|
row,
|
||||||
|
1,
|
||||||
|
QTableWidgetItem(str(r.get("src_vlan", "") or "untagged")),
|
||||||
|
)
|
||||||
|
self.redir_table.setItem(row, 2, QTableWidgetItem(r.get("dst_iface", "")))
|
||||||
|
self.redir_table.setItem(
|
||||||
|
row,
|
||||||
|
3,
|
||||||
|
QTableWidgetItem(str(r.get("dst_vlan", "") or "untagged")),
|
||||||
|
)
|
||||||
|
self.redir_table.setItem(row, 4, QTableWidgetItem(r.get("bridge", "")))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Redes (Gateway / DHCP)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _init_networks_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
group = QGroupBox("Configuración de red por VLAN / interfaz")
|
||||||
|
form = QFormLayout(group)
|
||||||
|
|
||||||
|
self.net_iface_combo = QComboBox()
|
||||||
|
self.net_ip_input = QLineEdit()
|
||||||
|
self.net_gw_input = QLineEdit()
|
||||||
|
self.net_dns_input = QLineEdit()
|
||||||
|
self.net_dhcp_cb = QCheckBox("Habilitar DHCP")
|
||||||
|
self.net_dhcp_start = QLineEdit()
|
||||||
|
self.net_dhcp_end = QLineEdit()
|
||||||
|
|
||||||
|
apply_btn = QPushButton("Aplicar configuración")
|
||||||
|
apply_btn.clicked.connect(self.on_apply_network_config_clicked)
|
||||||
|
|
||||||
|
form.addRow("Interfaz / VLAN:", self.net_iface_combo)
|
||||||
|
form.addRow("IP Gateway:", self.net_ip_input)
|
||||||
|
form.addRow("Gateway (next-hop):", self.net_gw_input)
|
||||||
|
form.addRow("DNS:", self.net_dns_input)
|
||||||
|
form.addRow(self.net_dhcp_cb)
|
||||||
|
form.addRow("DHCP Inicio:", self.net_dhcp_start)
|
||||||
|
form.addRow("DHCP Fin:", self.net_dhcp_end)
|
||||||
|
form.addRow("", apply_btn)
|
||||||
|
|
||||||
|
layout.addWidget(group)
|
||||||
|
|
||||||
|
self.net_table = QTableWidget()
|
||||||
|
self.net_table.setColumnCount(6)
|
||||||
|
self.net_table.setHorizontalHeaderLabels(
|
||||||
|
["Interfaz", "IP", "Gateway", "DHCP", "Rango", "DNS"]
|
||||||
|
)
|
||||||
|
self.net_table.horizontalHeader().setSectionResizeMode(
|
||||||
|
QHeaderView.ResizeMode.Stretch
|
||||||
|
)
|
||||||
|
layout.addWidget(self.net_table)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "Redes")
|
||||||
|
|
||||||
|
def on_apply_network_config_clicked(self):
|
||||||
|
iface = self.net_iface_combo.currentText()
|
||||||
|
ip = self.net_ip_input.text().strip()
|
||||||
|
gw = self.net_gw_input.text().strip()
|
||||||
|
dns = self.net_dns_input.text().strip() or None
|
||||||
|
dhcp = self.net_dhcp_cb.isChecked()
|
||||||
|
dhcp_start = self.net_dhcp_start.text().strip()
|
||||||
|
dhcp_end = self.net_dhcp_end.text().strip()
|
||||||
|
|
||||||
|
if not iface or not ip:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Error", "Interfaz y IP son obligatorios para la red."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if gw:
|
||||||
|
ok, msg = self.router.set_gateway_for_network(iface, ip, gw)
|
||||||
|
if not ok:
|
||||||
|
QMessageBox.critical(self, "Error gateway", msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
if dhcp:
|
||||||
|
if not (dhcp_start and dhcp_end):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Error",
|
||||||
|
"Para DHCP debes indicar rango de inicio y fin.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
ok, msg = self.router.start_dhcp_server_for_vlan(
|
||||||
|
vlan_interface=iface,
|
||||||
|
ip=ip,
|
||||||
|
range_start=dhcp_start,
|
||||||
|
range_end=dhcp_end,
|
||||||
|
gateway=gw or None,
|
||||||
|
dns=dns,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
QMessageBox.critical(self, "Error DHCP", msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
QMessageBox.information(self, "Red", "Configuración aplicada.")
|
||||||
|
self.refresh_networks_table()
|
||||||
|
|
||||||
|
def refresh_networks_table(self):
|
||||||
|
status = self.router.get_routing_status()
|
||||||
|
gws = status.get("gateways", {})
|
||||||
|
dhcps = status.get("dhcp_servers", {})
|
||||||
|
self.net_table.setRowCount(len(gws))
|
||||||
|
for row, (iface, data) in enumerate(gws.items()):
|
||||||
|
ip = data.get("ip", "")
|
||||||
|
gw = data.get("gateway", "")
|
||||||
|
dns = data.get("dns", "")
|
||||||
|
dh = dhcps.get(iface)
|
||||||
|
dh_enabled = "Sí" if dh else "No"
|
||||||
|
dh_range = ""
|
||||||
|
if dh:
|
||||||
|
dh_range = f"{dh.get('range_start','')} - {dh.get('range_end','')}"
|
||||||
|
|
||||||
|
self.net_table.setItem(row, 0, QTableWidgetItem(iface))
|
||||||
|
self.net_table.setItem(row, 1, QTableWidgetItem(ip))
|
||||||
|
self.net_table.setItem(row, 2, QTableWidgetItem(gw))
|
||||||
|
self.net_table.setItem(row, 3, QTableWidgetItem(dh_enabled))
|
||||||
|
self.net_table.setItem(row, 4, QTableWidgetItem(dh_range))
|
||||||
|
self.net_table.setItem(row, 5, QTableWidgetItem(dns))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Routing (rutas + reglas)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _init_routing_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
group = QGroupBox("Rutas estáticas")
|
||||||
|
form = QFormLayout(group)
|
||||||
|
|
||||||
|
self.route_dst_input = QLineEdit()
|
||||||
|
self.route_dst_input.setPlaceholderText("Ej: 10.0.0.0/24")
|
||||||
|
self.route_gw_input = QLineEdit()
|
||||||
|
self.route_iface_combo = QComboBox()
|
||||||
|
self.route_table_spin = QSpinBox()
|
||||||
|
self.route_table_spin.setRange(1, 252)
|
||||||
|
self.route_table_spin.setValue(254)
|
||||||
|
self.route_metric_spin = QSpinBox()
|
||||||
|
self.route_metric_spin.setRange(1, 1000)
|
||||||
|
self.route_metric_spin.setValue(100)
|
||||||
|
|
||||||
|
add_btn = QPushButton("Añadir ruta")
|
||||||
|
add_btn.clicked.connect(self.on_add_route_clicked)
|
||||||
|
|
||||||
|
form.addRow("Destino:", self.route_dst_input)
|
||||||
|
form.addRow("Gateway:", self.route_gw_input)
|
||||||
|
form.addRow("Interfaz:", self.route_iface_combo)
|
||||||
|
form.addRow("Tabla:", self.route_table_spin)
|
||||||
|
form.addRow("Métrica:", self.route_metric_spin)
|
||||||
|
form.addRow("", add_btn)
|
||||||
|
|
||||||
|
layout.addWidget(group)
|
||||||
|
|
||||||
|
self.routes_table = QTableWidget()
|
||||||
|
self.routes_table.setColumnCount(5)
|
||||||
|
self.routes_table.setHorizontalHeaderLabels(
|
||||||
|
["Destino", "Gateway", "Interfaz", "Tabla", "Métrica"]
|
||||||
|
)
|
||||||
|
self.routes_table.horizontalHeader().setSectionResizeMode(
|
||||||
|
QHeaderView.ResizeMode.Stretch
|
||||||
|
)
|
||||||
|
layout.addWidget(self.routes_table)
|
||||||
|
|
||||||
|
fw_group = QGroupBox("Reglas de routing (policy-based)")
|
||||||
|
fw_form = QFormLayout(fw_group)
|
||||||
|
|
||||||
|
self.rule_table_spin = QSpinBox()
|
||||||
|
self.rule_table_spin.setRange(1, 252)
|
||||||
|
self.rule_table_spin.setValue(100)
|
||||||
|
self.rule_prio_spin = QSpinBox()
|
||||||
|
self.rule_prio_spin.setRange(1, 32765)
|
||||||
|
self.rule_prio_spin.setValue(32000)
|
||||||
|
self.rule_src_input = QLineEdit()
|
||||||
|
self.rule_dst_input = QLineEdit()
|
||||||
|
self.rule_iif_input = QLineEdit()
|
||||||
|
self.rule_oif_input = QLineEdit()
|
||||||
|
add_rule_btn = QPushButton("Añadir regla")
|
||||||
|
add_rule_btn.clicked.connect(self.on_add_rule_clicked)
|
||||||
|
|
||||||
|
fw_form.addRow("Tabla:", self.rule_table_spin)
|
||||||
|
fw_form.addRow("Prioridad:", self.rule_prio_spin)
|
||||||
|
fw_form.addRow("Origen (CIDR):", self.rule_src_input)
|
||||||
|
fw_form.addRow("Destino (CIDR):", self.rule_dst_input)
|
||||||
|
fw_form.addRow("Interfaz entrada:", self.rule_iif_input)
|
||||||
|
fw_form.addRow("Interfaz salida:", self.rule_oif_input)
|
||||||
|
fw_form.addRow("", add_rule_btn)
|
||||||
|
|
||||||
|
layout.addWidget(fw_group)
|
||||||
|
|
||||||
|
enable_fwd_btn = QPushButton("Habilitar IP forwarding")
|
||||||
|
enable_fwd_btn.clicked.connect(self.on_enable_forwarding_clicked)
|
||||||
|
layout.addWidget(enable_fwd_btn)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "Routing")
|
||||||
|
|
||||||
|
def on_add_route_clicked(self):
|
||||||
|
dst = self.route_dst_input.text().strip()
|
||||||
|
gw = self.route_gw_input.text().strip()
|
||||||
|
iface = self.route_iface_combo.currentText()
|
||||||
|
table = self.route_table_spin.value()
|
||||||
|
metric = self.route_metric_spin.value()
|
||||||
|
|
||||||
|
if not (dst and gw and iface):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Error", "Destino, gateway e interfaz son obligatorios."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
ok, msg = self.router.add_route(
|
||||||
|
dst=dst, gateway=gw, interface=iface, table=table, metric=metric
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
QMessageBox.information(self, "Ruta", msg)
|
||||||
|
self.refresh_routes_table()
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(self, "Error ruta", msg)
|
||||||
|
|
||||||
|
def on_add_rule_clicked(self):
|
||||||
|
table = self.rule_table_spin.value()
|
||||||
|
prio = self.rule_prio_spin.value()
|
||||||
|
src = self.rule_src_input.text().strip() or None
|
||||||
|
dst = self.rule_dst_input.text().strip() or None
|
||||||
|
iif = self.rule_iif_input.text().strip() or None
|
||||||
|
oif = self.rule_oif_input.text().strip() or None
|
||||||
|
|
||||||
|
ok, msg = self.router.add_routing_rule(
|
||||||
|
table=table, priority=prio, src=src, dst=dst, iifname=iif, oifname=oif
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
QMessageBox.information(self, "Regla", msg)
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(self, "Error regla", msg)
|
||||||
|
|
||||||
|
def on_enable_forwarding_clicked(self):
|
||||||
|
ok, msg = self.router.enable_ip_forwarding()
|
||||||
|
if ok:
|
||||||
|
QMessageBox.information(self, "IP forwarding", msg)
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(self, "Error", msg)
|
||||||
|
|
||||||
|
def refresh_routes_table(self):
|
||||||
|
routes = self.router.list_routes()
|
||||||
|
self.routes_table.setRowCount(len(routes))
|
||||||
|
for row, r in enumerate(routes):
|
||||||
|
self.routes_table.setItem(row, 0, QTableWidgetItem(r.get("dst", "")))
|
||||||
|
self.routes_table.setItem(row, 1, QTableWidgetItem(r.get("gateway", "")))
|
||||||
|
self.routes_table.setItem(row, 2, QTableWidgetItem(r.get("interface", "")))
|
||||||
|
self.routes_table.setItem(
|
||||||
|
row, 3, QTableWidgetItem(str(r.get("table", "")))
|
||||||
|
)
|
||||||
|
self.routes_table.setItem(
|
||||||
|
row, 4, QTableWidgetItem(str(r.get("metric", "")))
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# NAT
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _init_nat_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
group = QGroupBox("NAT y Port Forwarding")
|
||||||
|
form = QFormLayout(group)
|
||||||
|
|
||||||
|
self.nat_internal_iface_combo = QComboBox()
|
||||||
|
self.nat_external_iface_combo = QComboBox()
|
||||||
|
enable_nat_btn = QPushButton("Habilitar NAT")
|
||||||
|
enable_nat_btn.clicked.connect(self.on_enable_nat_clicked)
|
||||||
|
|
||||||
|
self.pf_ext_ip = QLineEdit()
|
||||||
|
self.pf_ext_port = QSpinBox()
|
||||||
|
self.pf_ext_port.setRange(1, 65535)
|
||||||
|
self.pf_int_ip = QLineEdit()
|
||||||
|
self.pf_int_port = QSpinBox()
|
||||||
|
self.pf_int_port.setRange(1, 65535)
|
||||||
|
self.pf_proto_combo = QComboBox()
|
||||||
|
self.pf_proto_combo.addItems(["tcp", "udp"])
|
||||||
|
add_pf_btn = QPushButton("Añadir port forwarding")
|
||||||
|
add_pf_btn.clicked.connect(self.on_add_pf_clicked)
|
||||||
|
|
||||||
|
form.addRow("Interfaz interna:", self.nat_internal_iface_combo)
|
||||||
|
form.addRow("Interfaz externa:", self.nat_external_iface_combo)
|
||||||
|
form.addRow("", enable_nat_btn)
|
||||||
|
form.addRow(QLabel("— Port Forwarding —"))
|
||||||
|
form.addRow("IP externa:", self.pf_ext_ip)
|
||||||
|
form.addRow("Puerto externo:", self.pf_ext_port)
|
||||||
|
form.addRow("IP interna:", self.pf_int_ip)
|
||||||
|
form.addRow("Puerto interno:", self.pf_int_port)
|
||||||
|
form.addRow("Protocolo:", self.pf_proto_combo)
|
||||||
|
form.addRow("", add_pf_btn)
|
||||||
|
|
||||||
|
layout.addWidget(group)
|
||||||
|
|
||||||
|
self.pf_table = QTableWidget()
|
||||||
|
self.pf_table.setColumnCount(4)
|
||||||
|
self.pf_table.setHorizontalHeaderLabels(
|
||||||
|
["Externo", "Interno", "Proto", "Estado"]
|
||||||
|
)
|
||||||
|
self.pf_table.horizontalHeader().setSectionResizeMode(
|
||||||
|
QHeaderView.ResizeMode.Stretch
|
||||||
|
)
|
||||||
|
layout.addWidget(self.pf_table)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "NAT")
|
||||||
|
|
||||||
|
def on_enable_nat_clicked(self):
|
||||||
|
internal = self.nat_internal_iface_combo.currentText()
|
||||||
|
external = self.nat_external_iface_combo.currentText()
|
||||||
|
if not (internal and external):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Error",
|
||||||
|
"Selecciona interfaz interna y externa para habilitar NAT.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
ok, msg = self.router.enable_nat_for_interface(
|
||||||
|
interface=internal, external_iface=external
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
QMessageBox.information(self, "NAT", msg)
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(self, "Error NAT", msg)
|
||||||
|
|
||||||
|
def on_add_pf_clicked(self):
|
||||||
|
ext_ip = self.pf_ext_ip.text().strip()
|
||||||
|
ext_port = self.pf_ext_port.value()
|
||||||
|
int_ip = self.pf_int_ip.text().strip()
|
||||||
|
int_port = self.pf_int_port.value()
|
||||||
|
proto = self.pf_proto_combo.currentText()
|
||||||
|
|
||||||
|
if not (ext_ip and int_ip):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Error",
|
||||||
|
"Debes indicar IP externa e interna para el port forwarding.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
ok, msg = self.router.add_port_forwarding(
|
||||||
|
external_ip=ext_ip,
|
||||||
|
external_port=ext_port,
|
||||||
|
internal_ip=int_ip,
|
||||||
|
internal_port=int_port,
|
||||||
|
protocol=proto,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
QMessageBox.information(self, "Port forwarding", msg)
|
||||||
|
self.refresh_pf_table()
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(self, "Error port forwarding", msg)
|
||||||
|
|
||||||
|
def refresh_pf_table(self):
|
||||||
|
status = self.router.get_routing_status()
|
||||||
|
rules = status.get("nat_rules", [])
|
||||||
|
# Sólo mostramos las que tienen campos de port forwarding
|
||||||
|
pf_rules = [r for r in rules if "external_ip" in r]
|
||||||
|
self.pf_table.setRowCount(len(pf_rules))
|
||||||
|
for row, r in enumerate(pf_rules):
|
||||||
|
self.pf_table.setItem(
|
||||||
|
row,
|
||||||
|
0,
|
||||||
|
QTableWidgetItem(
|
||||||
|
f"{r.get('external_ip','')}:{r.get('external_port','')}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.pf_table.setItem(
|
||||||
|
row,
|
||||||
|
1,
|
||||||
|
QTableWidgetItem(
|
||||||
|
f"{r.get('internal_ip','')}:{r.get('internal_port','')}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.pf_table.setItem(row, 2, QTableWidgetItem(r.get("protocol", "")))
|
||||||
|
self.pf_table.setItem(row, 3, QTableWidgetItem("Activo"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Firewall / Visibilidad
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _init_firewall_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
group = QGroupBox("Visibilidad entre VLANs (simple)")
|
||||||
|
form = QFormLayout(group)
|
||||||
|
|
||||||
|
self.fw_src_vlan = QSpinBox()
|
||||||
|
self.fw_src_vlan.setRange(1, 4094)
|
||||||
|
self.fw_dst_vlan = QSpinBox()
|
||||||
|
self.fw_dst_vlan.setRange(1, 4094)
|
||||||
|
self.fw_allow_cb = QCheckBox("Permitir tráfico (si no, se bloquea)")
|
||||||
|
self.fw_allow_cb.setChecked(True)
|
||||||
|
apply_btn = QPushButton("Aplicar política")
|
||||||
|
apply_btn.clicked.connect(self.on_apply_vlan_visibility_clicked)
|
||||||
|
|
||||||
|
form.addRow("VLAN origen:", self.fw_src_vlan)
|
||||||
|
form.addRow("VLAN destino:", self.fw_dst_vlan)
|
||||||
|
form.addRow(self.fw_allow_cb)
|
||||||
|
form.addRow("", apply_btn)
|
||||||
|
|
||||||
|
layout.addWidget(group)
|
||||||
|
|
||||||
|
self.fw_table = QTableWidget()
|
||||||
|
self.fw_table.setColumnCount(3)
|
||||||
|
self.fw_table.setHorizontalHeaderLabels(["VLAN origen", "VLAN destino", "Permitido"])
|
||||||
|
self.fw_table.horizontalHeader().setSectionResizeMode(
|
||||||
|
QHeaderView.ResizeMode.Stretch
|
||||||
|
)
|
||||||
|
layout.addWidget(self.fw_table)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "Firewall")
|
||||||
|
|
||||||
|
def on_apply_vlan_visibility_clicked(self):
|
||||||
|
src = self.fw_src_vlan.value()
|
||||||
|
dst = self.fw_dst_vlan.value()
|
||||||
|
allow = self.fw_allow_cb.isChecked()
|
||||||
|
|
||||||
|
if allow:
|
||||||
|
ok, msg = self.router.allow_vlan_to_vlan(src, dst)
|
||||||
|
else:
|
||||||
|
ok, msg = self.router.block_vlan_to_vlan(src, dst)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
QMessageBox.information(self, "Firewall", msg)
|
||||||
|
self.refresh_fw_table()
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(self, "Error firewall", msg)
|
||||||
|
|
||||||
|
def refresh_fw_table(self):
|
||||||
|
status = self.router.get_routing_status()
|
||||||
|
vis = status.get("vlan_visibility", {})
|
||||||
|
items = list(vis.items())
|
||||||
|
self.fw_table.setRowCount(len(items))
|
||||||
|
for row, (k, allowed) in enumerate(items):
|
||||||
|
src_str, dst_str = k.split("-")
|
||||||
|
self.fw_table.setItem(row, 0, QTableWidgetItem(src_str))
|
||||||
|
self.fw_table.setItem(row, 1, QTableWidgetItem(dst_str))
|
||||||
|
self.fw_table.setItem(row, 2, QTableWidgetItem("Sí" if allowed else "No"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Utilidades comunes
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def refresh_interfaces(self):
|
||||||
|
interfaces = self.nm.list_interfaces()
|
||||||
|
names = [i["name"] for i in interfaces]
|
||||||
|
|
||||||
|
combos = [
|
||||||
|
self.vlan_iface_combo,
|
||||||
|
self.src_iface_combo,
|
||||||
|
self.dst_iface_combo,
|
||||||
|
self.net_iface_combo,
|
||||||
|
self.route_iface_combo,
|
||||||
|
self.nat_internal_iface_combo,
|
||||||
|
self.nat_external_iface_combo,
|
||||||
|
]
|
||||||
|
|
||||||
|
for combo in combos:
|
||||||
|
combo.clear()
|
||||||
|
combo.addItems(names)
|
||||||
|
|
||||||
|
# Tras refrescar interfaces, actualizamos también tablas en blanco
|
||||||
|
self.refresh_vlan_table()
|
||||||
|
self.refresh_redirection_table()
|
||||||
|
self.refresh_networks_table()
|
||||||
|
self.refresh_routes_table()
|
||||||
|
self.refresh_pf_table()
|
||||||
|
self.refresh_fw_table()
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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"]))
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Binary file not shown.
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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`).
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Informe de Comprobación VLAN35</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #008542;
|
||||||
|
border-bottom: 3px solid #008542;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #444;
|
||||||
|
margin-top: 30px;
|
||||||
|
border-left: 5px solid #008542;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
.header-info {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.stat-box {
|
||||||
|
flex: 1;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-success {
|
||||||
|
background-color: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
.stat-failed {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
.stat-total {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #008542;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
tr:nth-child(even) {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
.status-success {
|
||||||
|
color: #2e7d32;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.status-failed {
|
||||||
|
color: #c62828;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.timestamp {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Informe de Comprobación VLAN35</h1>
|
||||||
|
|
||||||
|
<div class="header-info">
|
||||||
|
<p><strong>Fecha y hora:</strong> 2026-02-16 11:10:26</p>
|
||||||
|
<p><strong>Total de tests ejecutados:</strong> 6</p>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-box stat-success">
|
||||||
|
<div style="font-size: 24px; font-weight: bold;">6</div>
|
||||||
|
<div>Exitosos</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box stat-failed">
|
||||||
|
<div style="font-size: 24px; font-weight: bold;">0</div>
|
||||||
|
<div>Fallidos</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box stat-total">
|
||||||
|
<div style="font-size: 24px; font-weight: bold;">6</div>
|
||||||
|
<div>Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Resultados Detallados</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Puerto</th>
|
||||||
|
<th>Protocolo</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Latencia (ms)</th>
|
||||||
|
<th>Detalles</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td><strong>10.47.8.133</strong></td>
|
||||||
|
<td>515</td>
|
||||||
|
<td>TCP</td>
|
||||||
|
<td class="status-success">Open</td>
|
||||||
|
<td>7.10</td>
|
||||||
|
<td>Conexión TCP exitosa en 7.10ms</td>
|
||||||
|
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td><strong>10.47.8.133</strong></td>
|
||||||
|
<td>9195</td>
|
||||||
|
<td>TCP</td>
|
||||||
|
<td class="status-success">Open</td>
|
||||||
|
<td>7.88</td>
|
||||||
|
<td>Conexión TCP exitosa en 7.88ms</td>
|
||||||
|
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td><strong>10.47.8.135</strong></td>
|
||||||
|
<td>9191</td>
|
||||||
|
<td>TCP</td>
|
||||||
|
<td class="status-success">Open</td>
|
||||||
|
<td>8.01</td>
|
||||||
|
<td>Conexión TCP exitosa en 8.01ms</td>
|
||||||
|
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td><strong>10.47.8.135</strong></td>
|
||||||
|
<td>9192</td>
|
||||||
|
<td>TCP</td>
|
||||||
|
<td class="status-success">Open</td>
|
||||||
|
<td>4.41</td>
|
||||||
|
<td>Conexión TCP exitosa en 4.41ms</td>
|
||||||
|
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td><strong>10.240.225.171</strong></td>
|
||||||
|
<td>465</td>
|
||||||
|
<td>TCP</td>
|
||||||
|
<td class="status-success">Open</td>
|
||||||
|
<td>3.96</td>
|
||||||
|
<td>Conexión TCP exitosa en 3.96ms</td>
|
||||||
|
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td><strong>10.244.255.250</strong></td>
|
||||||
|
<td>123</td>
|
||||||
|
<td>UDP</td>
|
||||||
|
<td class="status-success">Open</td>
|
||||||
|
<td>5.11</td>
|
||||||
|
<td>NTP hora local: 2026-02-16 11:25:01</td>
|
||||||
|
<td class="timestamp">2026-02-16 11:10:17</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Resumen por Host</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Tests Exitosos</th>
|
||||||
|
<th>Tests Fallidos</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td><strong>10.47.8.133</strong></td>
|
||||||
|
<td class="status-success">2</td>
|
||||||
|
<td class="status-failed">0</td>
|
||||||
|
<td>2</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td><strong>10.47.8.135</strong></td>
|
||||||
|
<td class="status-success">2</td>
|
||||||
|
<td class="status-failed">0</td>
|
||||||
|
<td>2</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td><strong>10.240.225.171</strong></td>
|
||||||
|
<td class="status-success">1</td>
|
||||||
|
<td class="status-failed">0</td>
|
||||||
|
<td>1</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td><strong>10.244.255.250</strong></td>
|
||||||
|
<td class="status-success">1</td>
|
||||||
|
<td class="status-failed">0</td>
|
||||||
|
<td>1</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user