scan port

This commit is contained in:
2026-02-07 18:53:18 +01:00
parent dff1b03e42
commit 4eb3defa59
11 changed files with 1037 additions and 3 deletions

View File

@@ -30,7 +30,8 @@
"Bash(git -C /home/gilles/docker/ipwatch fetch origin)",
"Bash(git -C /home/gilles/docker/ipwatch branch -m master main)",
"Bash(git -C /home/gilles/docker/ipwatch reset --soft origin/main)",
"Bash(git -C /home/gilles/docker/ipwatch branch:*)"
"Bash(git -C /home/gilles/docker/ipwatch branch:*)",
"Bash(docker-compose up:*)"
],
"deny": [],
"ask": []

132
README.md
View File

@@ -10,6 +10,8 @@ IPWatch est une application web de scan réseau qui visualise en temps réel l'
- 📝 **Gestion des IP** : Nommage, classification (connue/inconnue), métadonnées
- 📈 **Historique 24h** : Suivi de l'évolution de l'état du réseau
- 🔔 **Détection automatique** : Notification des nouvelles IP sur le réseau
- 🔗 **Intégration OPNsense** : Gestion des réservations DHCP Kea directement depuis l'interface
- 🌐 **Ports & Services** : Scan de services réseau (HTTP, SSH, Proxmox, etc.) avec résultats en temps réel et liens cliquables
- 🐳 **Déploiement Docker** : Configuration simple avec docker-compose
## Technologies
@@ -19,6 +21,7 @@ IPWatch est une application web de scan réseau qui visualise en temps réel l'
- **SQLAlchemy** - ORM pour SQLite
- **APScheduler** - Tâches planifiées
- **Scapy** - Scan ARP et réseau
- **httpx** - Client HTTP async (intégration OPNsense)
### Frontend
- **Vue 3** - Framework UI avec Composition API
@@ -90,6 +93,8 @@ Le fichier `config.yaml` permet de configurer :
- **Historique** : Durée de rétention
- **Interface** : Transparence, couleurs
- **Base de données** : Chemin SQLite
- **OPNsense** : Connexion API pour réservations DHCP Kea
- **Services** : Liste des services réseau à scanner (nom, port, protocole)
Exemple :
```yaml
@@ -113,6 +118,28 @@ ip_classes:
name: "Box Internet"
location: "Entrée"
host: "Routeur"
opnsense:
enabled: true
host: "10.0.0.1"
protocol: "http" # "http" ou "https"
api_key: "votre_api_key"
api_secret: "votre_api_secret"
verify_ssl: false
services:
- name: "HTTP"
port: 80
protocol: "http"
- name: "HTTPS"
port: 443
protocol: "https"
- name: "SSH"
port: 22
protocol: "ssh"
- name: "Proxmox VE"
port: 8006
protocol: "https"
```
## Interface utilisateur
@@ -123,6 +150,8 @@ L'interface est organisée en 3 colonnes :
- Informations détaillées de l'IP sélectionnée
- Formulaire d'édition (nom, localisation, type d'hôte)
- Informations réseau (MAC, vendor, hostname, ports ouverts)
- Bouton de réservation DHCP OPNsense (si MAC disponible)
- Indicateur "DHCP réservé" (checkbox en lecture seule)
### Colonne centrale - Grille d'IP
- Vue d'ensemble de toutes les IP du réseau
@@ -151,6 +180,18 @@ L'interface est organisée en 3 colonnes :
- `GET /api/ips/{ip}/history` - Historique d'une IP
- `GET /api/ips/stats/summary` - Statistiques globales
### Endpoints OPNsense (Kea DHCP)
- `GET /api/opnsense/status` - Test connexion OPNsense
- `GET /api/opnsense/dhcp/reservations` - Lister les réservations DHCP Kea
- `GET /api/opnsense/dhcp/reservation/{ip}` - Chercher une réservation par IP
- `POST /api/opnsense/dhcp/reservation` - Créer ou mettre à jour une réservation DHCP
### Endpoints Ports & Services
- `GET /api/services/list` - Liste des services configurés dans config.yaml
- `POST /api/services/scan` - Lancer un scan de services (ports sélectionnés sur toutes les IPs connues)
### Endpoints Scan
- `POST /api/scan/start` - Lancer un scan immédiat
@@ -165,6 +206,11 @@ Messages WebSocket :
- `scan_complete` - Fin de scan avec statistiques
- `ip_update` - Changement d'état d'une IP
- `new_ip` - Nouvelle IP détectée
- `service_scan_start` - Début de scan de services
- `service_scan_progress` - Progression du scan (IP en cours)
- `service_scan_log` - Log individuel par IP scannée
- `service_scan_result` - Résultat individuel en temps réel (service détecté)
- `service_scan_complete` - Fin de scan avec résultats agrégés
## Tests
@@ -181,6 +227,89 @@ Tests disponibles :
- `test_api.py` - Tests endpoints API
- `test_scheduler.py` - Tests scheduler APScheduler
## Intégration OPNsense / Kea DHCP
IPWatch peut se connecter à un pare-feu OPNsense pour gérer les réservations DHCP Kea directement depuis l'interface web.
### Configuration
Ajouter la section `opnsense` dans `config.yaml` :
```yaml
opnsense:
enabled: true
host: "10.0.0.1" # IP ou hostname OPNsense
protocol: "http" # "http" ou "https"
api_key: "votre_api_key"
api_secret: "votre_api_secret"
verify_ssl: false # false pour certificats auto-signés
```
Les clés API se génèrent dans OPNsense : **Système > Accès > Utilisateurs > [utilisateur] > Clés API**.
### Fonctionnement
1. Sélectionner un équipement dans la grille (il doit avoir une adresse MAC)
2. Cliquer sur le bouton **DHCP** (icone routeur) dans le volet gauche
3. Un popup s'ouvre avec les champs pré-remplis : MAC, IP, hostname, description
4. Cliquer **"Mettre à jour OPNsense"** pour créer ou mettre à jour la réservation
5. IPWatch résout automatiquement le subnet Kea correspondant à l'IP
6. Après succès, la checkbox **"DHCP réservé"** passe au vert
### Flux technique
```
IPWatch Frontend → POST /api/opnsense/dhcp/reservation
→ Résolution automatique du subnet UUID Kea
→ Recherche réservation existante par IP
→ Création ou mise à jour de la réservation
→ Reconfiguration du service Kea (application immédiate)
→ Mise à jour dhcp_synced=true en BDD
→ Retour au frontend (checkbox verte)
```
## Ports & Services
L'onglet **Ports & Services** permet de scanner le réseau à la recherche de services spécifiques et d'afficher les résultats dans un tableau avec des liens cliquables.
### Configuration
Les services à scanner sont définis dans la section `services` de `config.yaml` :
```yaml
services:
- name: "HTTP"
port: 80
protocol: "http"
- name: "Proxmox VE"
port: 8006
protocol: "https"
- name: "SSH"
port: 22
protocol: "ssh"
```
Chaque service a un `name`, un `port`, et un `protocol` optionnel (http, https, ssh, rdp, smb...). Le protocole détermine la construction des URLs cliquables.
### Fonctionnement
1. Ouvrir l'onglet **Ports & Services** depuis la barre de navigation
2. Cocher les services à scanner dans le volet gauche
3. Cliquer **"Lancer le scan"**
4. Le scan teste chaque IP connue (en base) pour les ports sélectionnés
5. Les résultats apparaissent **en temps réel** dans le tableau central
6. Les services HTTP/HTTPS sont affichés avec des liens cliquables
### Performances
Le scan est parallélisé : **20 IPs sont testées simultanément** (configurable via `PARALLEL_IPS`). Pour un réseau de 1000 IPs avec un timeout de 1s, le scan complet prend environ 50 secondes.
### Interface
- **Volet gauche** : Liste des services avec checkboxes de sélection
- **Zone centrale** : Barre de progression + tableau de résultats (IP, nom, service, port, lien)
- **Volet droit** : Logs de scan en temps réel (redimensionnable)
## Architecture
```
@@ -190,7 +319,7 @@ ipwatch/
│ │ ├── core/ # Configuration, database
│ │ ├── models/ # Modèles SQLAlchemy
│ │ ├── routers/ # Endpoints API
│ │ ├── services/ # Services réseau, scheduler, WebSocket
│ │ ├── services/ # Services réseau, scheduler, WebSocket, client OPNsense
│ │ └── main.py # Application FastAPI
│ └── requirements.txt
├── frontend/
@@ -198,6 +327,7 @@ ipwatch/
│ │ ├── assets/ # CSS Monokai
│ │ ├── components/ # Composants Vue
│ │ ├── stores/ # Pinia stores
│ │ ├── views/ # Vues (Main, Tracking, Services, Architecture, Test)
│ │ └── main.js
│ └── package.json
├── tests/ # Tests backend

View File

@@ -6,4 +6,5 @@ secret=rMOGHY+3SRfiT7cxpMoGZuwnPPRX0vPHV2oDTn6UPCvH87UXJe1qBkTs8y/ryG942TsTGe5UY
integrer les option dans config.yml et accessible egalement dans parametre
- [ ] ajouter un bouton dans volet gauche pour ajouter le parametrage d'un equipement dans opensense mappage static. possibilite d'ajouter des mappages avec des ip differentes de la plage de dhcp dans opnsense ?
- [ ] ajout backup de la bdd dans parametre
- [ ] brainstorming ajout d'un onglet opnsense qui presente des parametrages claire des services actif et des paramaetrage disponible (style tableau de bord) avec des tooltips explicatif clair, une section logs et erreur
- [ ] brainstorming ajout d'un onglet opnsense qui presente des parametrages claire des services actif et des paramaetrage disponible (style tableau de bord) avec des tooltips explicatif clair, une section logs et erreur
- [ ] intercale un bouton entre suivi et architecture nommé ports et service qui affiche une section de recherche d'ip en fonction d'un port ou d'un service. dans le volet gauche affiche une liste de service associé a son port dans la section centrale un bouton de scan. le scan effectuera une recherche pour chaqu ip si le service coché est actif ou pas et mettra a disposition un tableau de resultat clair et lisible avec un lien url clicable vers le service. dans le volet gauche un listing stocké dans config.yaml- tu generera deja un premier listing avec les services les plus connu avec leur port ( web, proxmox, arcane, ....)

View File

@@ -82,6 +82,14 @@ class OPNsenseConfig(BaseModel):
protocol: str = "http" # "http" ou "https"
class ServiceDefinition(BaseModel):
"""Définition d'un service réseau (pour scan de services)"""
name: str
port: int
protocol: Optional[str] = None # http, https, ssh, rdp, smb...
description: str = ""
class DatabaseConfig(BaseModel):
"""Configuration base de données"""
path: str = "./data/db.sqlite"
@@ -123,6 +131,7 @@ class IPWatchConfig(BaseModel):
colors: ColorsConfig = Field(default_factory=ColorsConfig)
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
opnsense: OPNsenseConfig = Field(default_factory=OPNsenseConfig)
services: List[ServiceDefinition] = Field(default_factory=list)
class ConfigManager:

View File

@@ -17,6 +17,7 @@ from backend.app.routers import config as config_router
from backend.app.routers import system as system_router
from backend.app.routers import tracking as tracking_router
from backend.app.routers import opnsense as opnsense_router
from backend.app.routers import services as services_router
from backend.app.services.scheduler import scan_scheduler
from backend.app.routers.scan import perform_scan
@@ -129,6 +130,7 @@ app.include_router(system_router.router)
app.include_router(tracking_router.router)
app.include_router(architecture_router.router)
app.include_router(opnsense_router.router)
app.include_router(services_router.router)
# Servir les ressources d'architecture
architecture_dir = Path("./architecture")

View File

@@ -0,0 +1,224 @@
"""
Router pour l'onglet Ports & Services
Scan de services réseau sur les IPs connues — parallélisé par batch
"""
import asyncio
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Dict, Any
from backend.app.core.database import get_db
from backend.app.core.config import config_manager
from backend.app.models.ip import IP
from backend.app.services.network import NetworkScanner
from backend.app.services.websocket import ws_manager
router = APIRouter(prefix="/api/services", tags=["Services"])
# Verrou pour empêcher les scans simultanés
_scan_lock = asyncio.Lock()
# Nombre d'IPs scannées en parallèle
PARALLEL_IPS = 20
class ServiceScanRequest(BaseModel):
"""Requête de scan de services"""
ports: List[int]
@router.get("/list")
async def list_services():
"""Retourne la liste des services configurés dans config.yaml"""
config = config_manager.config
return {
"services": [s.model_dump() for s in config.services]
}
async def _scan_single_ip(
scanner: NetworkScanner,
ip_info: Dict[str, str],
selected_ports: List[int],
port_to_service: Dict[int, Dict[str, Any]],
idx: int,
total_ips: int,
semaphore: asyncio.Semaphore
) -> list:
"""Scanne une IP avec sémaphore de concurrence"""
async with semaphore:
ip_addr = ip_info["ip"]
try:
open_ports = await scanner.scan_ports(ip_addr, selected_ports)
except Exception as e:
print(f"[Services] Erreur scan {ip_addr}: {e}")
await ws_manager.broadcast({
"type": "service_scan_log",
"message": f"{ip_addr} — Erreur: {e}"
})
return []
# Log du résultat
if open_ports:
port_names = []
for p in open_ports:
svc = port_to_service.get(p, {"name": str(p)})
port_names.append(f"{svc['name']}({p})")
await ws_manager.broadcast({
"type": "service_scan_log",
"message": f"{ip_addr}{', '.join(port_names)}"
})
else:
await ws_manager.broadcast({
"type": "service_scan_log",
"message": f"{ip_addr} — aucun service"
})
# Construire les résultats et les envoyer en temps réel
ip_results = []
for port in open_ports:
svc_info = port_to_service.get(port, {"name": f"Port {port}", "protocol": None})
protocol = svc_info["protocol"]
url = None
if protocol in ("http", "https"):
if (protocol == "http" and port == 80) or (protocol == "https" and port == 443):
url = f"{protocol}://{ip_addr}"
else:
url = f"{protocol}://{ip_addr}:{port}"
result_entry = {
"ip": ip_addr,
"name": ip_info["name"],
"hostname": ip_info["hostname"],
"port": port,
"service_name": svc_info["name"],
"protocol": protocol or "",
"url": url
}
ip_results.append(result_entry)
# Envoyer le résultat en temps réel
await ws_manager.broadcast({
"type": "service_scan_result",
"result": result_entry
})
return ip_results
async def _run_service_scan(selected_ports: List[int], ip_list: list):
"""Tâche de fond : scanne les IPs en parallèle et envoie les résultats via WebSocket"""
config = config_manager.config
# Mapping port → service
port_to_service = {}
for svc in config.services:
port_to_service[svc.port] = {
"name": svc.name,
"protocol": svc.protocol
}
total_ips = len(ip_list)
scanner = NetworkScanner(
cidr=config.network.cidr,
timeout=config.scan.timeout,
ping_count=config.scan.ping_count
)
await ws_manager.broadcast({
"type": "service_scan_start",
"message": f"Scan démarré ({len(selected_ports)} ports, {total_ips} IPs, {PARALLEL_IPS} en parallèle)"
})
# Sémaphore pour limiter la concurrence
semaphore = asyncio.Semaphore(PARALLEL_IPS)
completed = 0
async def scan_with_progress(ip_info, idx):
nonlocal completed
result = await _scan_single_ip(
scanner, ip_info, selected_ports, port_to_service,
idx, total_ips, semaphore
)
completed += 1
# Progression
await ws_manager.broadcast({
"type": "service_scan_progress",
"current": completed,
"total": total_ips,
"ip": ip_info["ip"]
})
return result
# Lancer toutes les IPs en parallèle (limité par sémaphore)
tasks = [
scan_with_progress(ip_info, idx)
for idx, ip_info in enumerate(ip_list)
]
all_results = await asyncio.gather(*tasks)
# Aplatir les résultats
results = []
for ip_results in all_results:
results.extend(ip_results)
# Trier par IP puis par port
results.sort(key=lambda r: (
tuple(int(p) for p in r["ip"].split(".")),
r["port"]
))
stats = {
"total_ips": total_ips,
"total_found": len(results),
"scan_time": datetime.now().isoformat()
}
await ws_manager.broadcast({
"type": "service_scan_complete",
"message": f"Scan terminé : {len(results)} services détectés sur {total_ips} IPs",
"stats": stats,
"results": results
})
@router.post("/scan")
async def scan_services(request: ServiceScanRequest, db: Session = Depends(get_db)):
"""
Lance un scan de services en tâche de fond (parallélisé).
Les résultats sont envoyés via WebSocket.
"""
selected_ports = request.ports
if not selected_ports:
return {"status": "error", "message": "Aucun port sélectionné"}
if _scan_lock.locked():
return {"status": "error", "message": "Un scan de services est déjà en cours"}
# Récupérer la liste des IPs
all_ips = db.query(IP).filter(IP.last_seen.isnot(None)).all()
ip_list = [
{"ip": ip.ip, "name": ip.name or "", "hostname": ip.hostname or ""}
for ip in all_ips
]
if not ip_list:
return {"status": "error", "message": "Aucune IP connue en base"}
async def locked_scan():
async with _scan_lock:
await _run_service_scan(selected_ports, ip_list)
asyncio.create_task(locked_scan())
return {
"status": "started",
"message": f"Scan lancé pour {len(selected_ports)} ports sur {len(ip_list)} IPs ({PARALLEL_IPS} en parallèle)",
"total_ips": len(ip_list)
}

View File

@@ -177,3 +177,80 @@ opnsense:
database:
path: "./data/db.sqlite"
# Services réseau pour l'onglet Ports & Services
services:
- name: "HTTP"
port: 80
protocol: "http"
- name: "HTTPS"
port: 443
protocol: "https"
- name: "SSH"
port: 22
protocol: "ssh"
- name: "RDP"
port: 3389
protocol: "rdp"
- name: "SMB/CIFS"
port: 445
protocol: "smb"
- name: "FTP"
port: 21
protocol: "ftp"
- name: "DNS"
port: 53
- name: "MySQL"
port: 3306
- name: "PostgreSQL"
port: 5432
- name: "Proxmox VE"
port: 8006
protocol: "https"
- name: "Proxmox Backup"
port: 8007
protocol: "https"
- name: "Home Assistant"
port: 8123
protocol: "http"
- name: "Node-RED"
port: 1880
protocol: "http"
- name: "Jellyfin"
port: 8096
protocol: "http"
- name: "Plex"
port: 32400
protocol: "http"
- name: "Gitea"
port: 3000
protocol: "https"
- name: "Portainer"
port: 9443
protocol: "https"
- name: "Synology DSM"
port: 5000
protocol: "http"
- name: "Unifi Controller"
port: 8443
protocol: "https"
- name: "Arcane"
port: 3552
protocol: "http"
- name: "ESPHome"
port: 6053
- name: "Cockpit"
port: 9090
protocol: "https"
- name: "HTTP Alt (8080)"
port: 8080
protocol: "http"
- name: "HTTP Alt (8081)"
port: 8081
protocol: "http"
- name: "HTTP Alt (9000)"
port: 9000
protocol: "http"
- name: "ESP8266"
port: 8266
protocol: "http"

View File

@@ -69,6 +69,15 @@
<span class="mdi mdi-star"></span> Suivi
</button>
<!-- Bouton Ports & Services -->
<button
@click="goToServices"
class="px-3 py-1.5 rounded bg-monokai-cyan/80 text-monokai-bg text-sm font-bold hover:bg-monokai-cyan transition-colors"
title="Scanner les services réseau"
>
<span class="mdi mdi-lan-check"></span> Ports & Services
</button>
<!-- Bouton Architecture -->
<button
@click="goToArchitecture"
@@ -158,6 +167,10 @@ function goToTracking() {
router.push('/tracking')
}
function goToServices() {
router.push('/services')
}
function goToArchitecture() {
router.push('/architecture')
}

View File

@@ -7,6 +7,7 @@ import MainView from '@/views/MainView.vue'
import TrackingView from '@/views/TrackingView.vue'
import ArchitectureView from '@/views/ArchitectureView.vue'
import TestView from '@/views/TestView.vue'
import ServicesView from '@/views/ServicesView.vue'
const routes = [
{
@@ -33,6 +34,14 @@ const routes = [
title: 'IPWatch - Architecture réseau'
}
},
{
path: '/services',
name: 'services',
component: ServicesView,
meta: {
title: 'IPWatch - Ports & Services'
}
},
{
path: '/test',
name: 'test',

View File

@@ -348,6 +348,7 @@ export const useIPStore = defineStore('ip', () => {
stats,
searchQuery,
invertSearch,
ws,
wsConnected,
lastScanDate,
scanProgress,

View File

@@ -0,0 +1,567 @@
<template>
<div class="h-screen flex flex-col bg-monokai-bg">
<!-- Header -->
<header class="bg-monokai-bg border-b-2 border-monokai-comment p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<h1 class="text-3xl font-bold text-monokai-green">IPWatch</h1>
<div class="flex flex-col">
<span class="text-monokai-comment">Ports & Services</span>
</div>
</div>
<div class="flex items-center gap-6">
<SystemStats />
<div class="h-8 w-px bg-monokai-comment"></div>
<button
@click="goToDashboard"
class="px-4 py-2 rounded bg-monokai-yellow text-monokai-bg font-bold hover:bg-monokai-orange transition-colors"
title="Retour au dashboard"
>
<span class="mdi mdi-arrow-left"></span> Dashboard
</button>
<button
@click="openSettings"
class="px-4 py-2 rounded bg-monokai-purple text-monokai-bg text-sm hover:bg-monokai-pink transition-colors"
title="Ouvrir les paramètres"
>
<span class="mdi mdi-cog"></span> Paramètres
</button>
</div>
</div>
</header>
<!-- Layout 3 colonnes -->
<div class="flex-1 flex overflow-hidden">
<!-- Volet gauche : Liste des services -->
<div class="w-80 flex-shrink-0 border-r border-monokai-comment flex flex-col">
<div class="p-4 border-b border-monokai-comment">
<h2 class="text-lg font-bold text-monokai-cyan flex items-center gap-2">
<span class="mdi mdi-server-network"></span>
Services
<span class="text-xs text-monokai-comment font-normal">({{ selectedCount }}/{{ services.length }})</span>
</h2>
</div>
<!-- Liste scrollable des services -->
<div class="flex-1 overflow-auto p-2">
<div
v-for="service in services"
:key="service.name + service.port"
class="flex items-center gap-2 px-3 py-1.5 rounded hover:bg-monokai-comment/20 cursor-pointer transition-colors"
@click="toggleService(service)"
>
<input
type="checkbox"
:checked="isSelected(service)"
class="form-checkbox accent-monokai-cyan"
@click.stop
@change="toggleService(service)"
/>
<span class="flex-1 text-sm text-monokai-text truncate">{{ service.name }}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-monokai-comment/30 text-monokai-comment font-mono">
{{ service.port }}
</span>
<span v-if="service.protocol" class="text-xs text-monokai-purple">
{{ service.protocol }}
</span>
</div>
<div v-if="services.length === 0" class="text-center text-monokai-comment text-sm p-4">
Aucun service configuré dans config.yaml
</div>
</div>
<!-- Boutons de sélection -->
<div class="p-3 border-t border-monokai-comment flex gap-2">
<button
@click="selectAll"
class="flex-1 px-2 py-1.5 rounded text-xs font-bold bg-monokai-comment/30 text-monokai-text hover:bg-monokai-comment/50 transition-colors"
>
Tout cocher
</button>
<button
@click="selectNone"
class="flex-1 px-2 py-1.5 rounded text-xs font-bold bg-monokai-comment/30 text-monokai-text hover:bg-monokai-comment/50 transition-colors"
>
Tout décocher
</button>
</div>
</div>
<!-- Zone centrale : Scan et résultats -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Barre d'action -->
<div class="p-4 border-b border-monokai-comment">
<div class="flex items-center gap-4">
<button
@click="launchScan"
:disabled="selectedCount === 0 || isScanning"
class="px-4 py-2 rounded font-bold transition-colors"
:class="selectedCount === 0 || isScanning
? 'bg-monokai-comment/30 text-monokai-comment cursor-not-allowed'
: 'bg-monokai-cyan text-monokai-bg hover:bg-monokai-green'"
>
<span class="mdi mdi-magnify-scan"></span>
{{ isScanning ? 'Scan en cours...' : 'Lancer le scan' }}
</button>
<!-- Progression -->
<div v-if="isScanning" class="flex items-center gap-3 flex-1">
<div class="flex-1 h-2 bg-monokai-comment/30 rounded-full overflow-hidden">
<div
class="h-full bg-monokai-cyan transition-all duration-300 rounded-full"
:style="{ width: progressPercent + '%' }"
></div>
</div>
<span class="text-xs text-monokai-cyan whitespace-nowrap">
{{ scanProgress.current }} / {{ scanProgress.total }}
</span>
<span v-if="scanProgress.ip" class="text-xs text-monokai-comment">
{{ scanProgress.ip }}
</span>
</div>
<!-- Stats résultat -->
<div v-if="!isScanning && results.length > 0" class="text-sm text-monokai-comment">
{{ filteredResults.length }} service(s) détecté(s)
<span v-if="filterText" class="text-monokai-purple">(filtré)</span>
</div>
</div>
<!-- Message informatif sous le scan -->
<div v-if="isScanning" class="mt-2 text-xs text-monokai-comment flex items-center gap-2">
<span class="mdi mdi-information-outline text-monokai-cyan"></span>
Scan de {{ selectedCount }} service(s) sur l'ensemble des IPs connues du réseau.
Chaque IP est testée avec un timeout de {{ scanTimeout }}s.
<span v-if="scanProgress.total > 0" class="text-monokai-yellow">
Temps estimé : ~{{ estimatedTime }}
</span>
</div>
</div>
<!-- Tableau de résultats -->
<div v-if="results.length > 0 || isScanning" class="flex-1 overflow-auto">
<!-- Filtre texte -->
<div class="px-4 py-2 sticky top-0 bg-monokai-bg border-b border-monokai-comment/50 z-10">
<input
v-model="filterText"
type="text"
placeholder="Filtrer les résultats (IP, nom, service...)"
class="w-full px-3 py-1.5 rounded bg-monokai-comment/20 text-monokai-text text-sm border border-monokai-comment/30 focus:border-monokai-cyan focus:outline-none"
/>
</div>
<table class="w-full text-sm">
<thead class="sticky top-[41px] bg-monokai-bg z-10">
<tr class="text-monokai-comment border-b border-monokai-comment">
<th class="text-left px-4 py-2 cursor-pointer hover:text-monokai-text" @click="sortBy('ip')">
IP {{ sortIcon('ip') }}
</th>
<th class="text-left px-4 py-2 cursor-pointer hover:text-monokai-text" @click="sortBy('name')">
Nom {{ sortIcon('name') }}
</th>
<th class="text-left px-4 py-2 cursor-pointer hover:text-monokai-text" @click="sortBy('service_name')">
Service {{ sortIcon('service_name') }}
</th>
<th class="text-left px-4 py-2 cursor-pointer hover:text-monokai-text" @click="sortBy('port')">
Port {{ sortIcon('port') }}
</th>
<th class="text-left px-4 py-2">Lien</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in filteredResults"
:key="row.ip + '-' + row.port"
class="border-b border-monokai-comment/20 hover:bg-monokai-comment/10 transition-colors"
:class="index % 2 === 0 ? 'bg-monokai-bg' : 'bg-monokai-comment/5'"
>
<td class="px-4 py-2 font-mono text-monokai-cyan">{{ row.ip }}</td>
<td class="px-4 py-2 text-monokai-text">{{ row.name || row.hostname || '-' }}</td>
<td class="px-4 py-2">
<span class="px-2 py-0.5 rounded text-xs font-bold"
:class="serviceColor(row.protocol)">
{{ row.service_name }}
</span>
</td>
<td class="px-4 py-2 font-mono text-monokai-purple">{{ row.port }}</td>
<td class="px-4 py-2">
<a
v-if="row.url"
:href="row.url"
target="_blank"
rel="noopener noreferrer"
class="text-monokai-green hover:text-monokai-yellow underline flex items-center gap-1"
>
<span class="mdi mdi-open-in-new text-xs"></span>
{{ row.url }}
</a>
<span v-else-if="row.protocol" class="text-monokai-comment text-xs">
{{ row.protocol }}://{{ row.ip }}:{{ row.port }}
</span>
<span v-else class="text-monokai-comment text-xs">{{ row.ip }}:{{ row.port }}</span>
</td>
</tr>
<!-- Message pendant le scan si pas encore de résultats -->
<tr v-if="filteredResults.length === 0 && isScanning">
<td colspan="5" class="px-4 py-8 text-center text-monokai-comment">
<span class="mdi mdi-magnify-scan text-2xl block mb-2 animate-pulse"></span>
Scan en cours, en attente de résultats...
</td>
</tr>
</tbody>
</table>
</div>
<!-- État vide -->
<div v-else-if="!isScanning" class="flex-1 flex items-center justify-center">
<div class="text-center text-monokai-comment">
<span class="mdi mdi-lan-check text-6xl block mb-4 opacity-30"></span>
<div class="text-lg">Sélectionnez des services et lancez un scan</div>
<div class="text-sm mt-2 opacity-60">
Les résultats afficheront les services actifs sur chaque IP du réseau
</div>
</div>
</div>
</div>
<!-- Volet droit : Logs -->
<div
class="relative flex-shrink-0 border-l border-monokai-comment"
:style="{ width: `${rightPanelWidth}px` }"
>
<!-- Poignée de redimensionnement -->
<div
class="absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize"
@mousedown.prevent="startResize"
></div>
<div class="absolute left-1 top-1/2 -translate-y-1/2 flex flex-col gap-1 opacity-70 pointer-events-none">
<span class="w-1.5 h-1.5 rounded-full bg-monokai-comment/70"></span>
<span class="w-1.5 h-1.5 rounded-full bg-monokai-comment/70"></span>
<span class="w-1.5 h-1.5 rounded-full bg-monokai-comment/70"></span>
<span class="w-1.5 h-1.5 rounded-full bg-monokai-comment/70"></span>
</div>
<div class="p-4 h-full flex flex-col">
<h2 class="text-lg font-bold text-monokai-pink flex items-center gap-2">
<span class="mdi mdi-text-box-outline"></span>
Logs du scan
</h2>
<div class="mt-3 bg-monokai-bg border border-monokai-comment rounded flex-1 min-h-0 overflow-auto">
<pre class="text-xs text-monokai-text font-mono p-3 whitespace-pre-wrap">{{ logs.length ? logs.join('\n') : 'Aucun log pour le moment.' }}</pre>
</div>
<button
v-if="logs.length > 0"
@click="clearLogs"
class="mt-2 px-3 py-1 rounded text-xs bg-monokai-comment/30 text-monokai-comment hover:bg-monokai-comment/50 transition-colors"
>
Effacer les logs
</button>
</div>
</div>
</div>
<!-- Modal Paramètres -->
<SettingsModal
:isOpen="showSettings"
@close="showSettings = false"
@configReloaded="handleConfigReloaded"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useIPStore } from '@/stores/ipStore'
import SystemStats from '@/components/SystemStats.vue'
import SettingsModal from '@/components/SettingsModal.vue'
import axios from 'axios'
const router = useRouter()
const ipStore = useIPStore()
const showSettings = ref(false)
// --- Données ---
const services = ref([])
const selectedPorts = ref(new Set())
const results = ref([])
const isScanning = ref(false)
const scanProgress = ref({ current: 0, total: 0, ip: '' })
const logs = ref([])
const filterText = ref('')
const sortColumn = ref('ip')
const sortAsc = ref(true)
// --- Panneau droit redimensionnable ---
const rightPanelWidth = ref(384)
const minPanelWidth = 288
const maxPanelWidth = 576
let isResizing = false
let startX = 0
let startWidth = 0
// --- Computed ---
const selectedCount = computed(() => selectedPorts.value.size)
const progressPercent = computed(() => {
if (scanProgress.value.total === 0) return 0
return Math.round((scanProgress.value.current / scanProgress.value.total) * 100)
})
const scanTimeout = ref(1.0)
const estimatedTime = computed(() => {
const total = scanProgress.value.total
const remaining = total - scanProgress.value.current
const seconds = Math.ceil(remaining * scanTimeout.value)
if (seconds < 60) return `${seconds}s`
const min = Math.floor(seconds / 60)
const sec = seconds % 60
return sec > 0 ? `${min}min ${sec}s` : `${min}min`
})
const filteredResults = computed(() => {
let data = [...results.value]
// Filtre texte
if (filterText.value) {
const search = filterText.value.toLowerCase()
data = data.filter(r =>
r.ip.includes(search) ||
(r.name || '').toLowerCase().includes(search) ||
(r.hostname || '').toLowerCase().includes(search) ||
r.service_name.toLowerCase().includes(search) ||
String(r.port).includes(search) ||
(r.protocol || '').toLowerCase().includes(search)
)
}
// Tri
data.sort((a, b) => {
let va = a[sortColumn.value]
let vb = b[sortColumn.value]
// Tri numérique pour IP
if (sortColumn.value === 'ip') {
const toNum = ip => ip.split('.').reduce((acc, oct) => acc * 256 + parseInt(oct), 0)
va = toNum(va)
vb = toNum(vb)
}
// Tri numérique pour port
if (sortColumn.value === 'port') {
va = Number(va)
vb = Number(vb)
}
if (va < vb) return sortAsc.value ? -1 : 1
if (va > vb) return sortAsc.value ? 1 : -1
// Tri secondaire : par port si même IP, par IP si même colonne
if (sortColumn.value === 'ip') return a.port - b.port
const toNum = ip => ip.split('.').reduce((acc, oct) => acc * 256 + parseInt(oct), 0)
return toNum(a.ip) - toNum(b.ip)
})
return data
})
// --- Méthodes ---
function isSelected(service) {
return selectedPorts.value.has(service.port)
}
function toggleService(service) {
const ports = new Set(selectedPorts.value)
if (ports.has(service.port)) {
ports.delete(service.port)
} else {
ports.add(service.port)
}
selectedPorts.value = ports
}
function selectAll() {
selectedPorts.value = new Set(services.value.map(s => s.port))
}
function selectNone() {
selectedPorts.value = new Set()
}
function sortBy(column) {
if (sortColumn.value === column) {
sortAsc.value = !sortAsc.value
} else {
sortColumn.value = column
sortAsc.value = true
}
}
function sortIcon(column) {
if (sortColumn.value !== column) return ''
return sortAsc.value ? '▲' : '▼'
}
function serviceColor(protocol) {
if (!protocol) return 'bg-monokai-comment/30 text-monokai-comment'
switch (protocol) {
case 'http':
case 'https':
return 'bg-monokai-green/20 text-monokai-green'
case 'ssh':
return 'bg-monokai-cyan/20 text-monokai-cyan'
case 'rdp':
return 'bg-monokai-orange/20 text-monokai-orange'
case 'smb':
case 'ftp':
return 'bg-monokai-yellow/20 text-monokai-yellow'
default:
return 'bg-monokai-purple/20 text-monokai-purple'
}
}
async function loadServices() {
try {
const response = await axios.get('/api/services/list')
services.value = response.data.services || []
} catch (error) {
console.error('Erreur chargement services:', error)
addLog('Erreur chargement de la liste des services')
}
}
function addLog(message) {
const now = new Date().toLocaleTimeString('fr-FR')
logs.value.push(`[${now}] ${message}`)
}
function clearLogs() {
logs.value = []
}
async function launchScan() {
if (selectedCount.value === 0 || isScanning.value) return
isScanning.value = true
results.value = []
scanProgress.value = { current: 0, total: 0, ip: '' }
const ports = Array.from(selectedPorts.value)
const serviceNames = ports.map(p => {
const svc = services.value.find(s => s.port === p)
return svc ? svc.name : `Port ${p}`
})
addLog(`Scan lancé pour ${ports.length} service(s) : ${serviceNames.join(', ')}`)
try {
const response = await axios.post('/api/services/scan', { ports })
if (response.data.status === 'error') {
addLog(`Erreur : ${response.data.message}`)
isScanning.value = false
}
// Les résultats arrivent via WebSocket (service_scan_complete)
} catch (error) {
console.error('Erreur scan services:', error)
addLog(`Erreur : ${error.response?.data?.detail || error.message}`)
isScanning.value = false
}
}
// --- WebSocket : écouter la progression du scan ---
function handleWsMessage(event) {
try {
const data = JSON.parse(event.data)
if (data.type === 'service_scan_progress') {
scanProgress.value = {
current: data.current || 0,
total: data.total || 0,
ip: data.ip || ''
}
} else if (data.type === 'service_scan_start') {
addLog(data.message || 'Scan de services démarré')
} else if (data.type === 'service_scan_log') {
addLog(data.message || '')
} else if (data.type === 'service_scan_result') {
// Résultat individuel en temps réel
if (data.result) {
results.value.push(data.result)
}
} else if (data.type === 'service_scan_complete') {
addLog(data.message || 'Scan de services terminé')
// Mettre à jour avec les résultats finaux (triés)
if (data.results) {
results.value = data.results
}
isScanning.value = false
scanProgress.value = { current: 0, total: 0, ip: '' }
}
} catch {
// Message non-JSON, ignorer
}
}
// --- Navigation ---
function goToDashboard() {
router.push('/')
}
function openSettings() {
showSettings.value = true
}
async function handleConfigReloaded() {
await ipStore.fetchIPs()
ipStore.bumpConfigReload()
await loadServices()
}
// --- Redimensionnement panneau droit ---
function clampWidth(value) {
return Math.max(minPanelWidth, Math.min(maxPanelWidth, value))
}
function onMouseMove(event) {
if (!isResizing) return
const delta = startX - event.clientX
rightPanelWidth.value = clampWidth(startWidth + delta)
}
function stopResize() {
if (!isResizing) return
isResizing = false
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', stopResize)
}
function startResize(event) {
isResizing = true
startX = event.clientX
startWidth = rightPanelWidth.value
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', stopResize)
}
// --- Lifecycle ---
onMounted(async () => {
await loadServices()
// Écouter les messages WebSocket pour la progression
if (ipStore.ws) {
ipStore.ws.addEventListener('message', handleWsMessage)
}
})
onBeforeUnmount(() => {
stopResize()
if (ipStore.ws) {
ipStore.ws.removeEventListener('message', handleWsMessage)
}
})
</script>