diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 46e6cdb..ee9012e 100755 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/README.md b/README.md index 620c5c0..af3809e 100755 --- a/README.md +++ b/README.md @@ -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 diff --git a/amelioration.md b/amelioration.md index 03d344e..6bca87c 100644 --- a/amelioration.md +++ b/amelioration.md @@ -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 \ No newline at end of file +- [ ] 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, ....) \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 65f5579..85d0982 100755 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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: diff --git a/backend/app/main.py b/backend/app/main.py index c4332e1..4f1ed6e 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/routers/services.py b/backend/app/routers/services.py new file mode 100644 index 0000000..6fee056 --- /dev/null +++ b/backend/app/routers/services.py @@ -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) + } diff --git a/config.yaml b/config.yaml index 66bbfd7..95fcaa4 100755 --- a/config.yaml +++ b/config.yaml @@ -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" diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 8db2ca7..02925e7 100755 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -69,6 +69,15 @@ Suivi + + +