Files
ipwatch/backend/app/services/network.py
2026-02-07 16:57:37 +01:00

366 lines
11 KiB
Python
Executable File

"""
Modules réseau pour scan d'IP, ping, ARP et port scan
Implémente le workflow de scan selon workflow-scan.md
"""
import asyncio
import ipaddress
import platform
import subprocess
import socket
from typing import List, Dict, Optional, Tuple
from datetime import datetime
import re
from pathlib import Path
# Scapy pour ARP
try:
from scapy.all import ARP, Ether, srp
SCAPY_AVAILABLE = True
except ImportError:
SCAPY_AVAILABLE = False
class NetworkScanner:
"""Scanner réseau principal"""
def __init__(self, cidr: str, timeout: float = 1.0, ping_count: int = 1):
"""
Initialise le scanner réseau
Args:
cidr: Réseau CIDR (ex: "192.168.1.0/24")
timeout: Timeout pour ping et connexions (secondes)
ping_count: Nombre de ping par IP
"""
self.cidr = cidr
self.timeout = timeout
self.ping_count = max(1, int(ping_count))
self.network = ipaddress.ip_network(cidr, strict=False)
def generate_ip_list(self) -> List[str]:
"""
Génère la liste complète d'IP depuis le CIDR
Returns:
Liste des adresses IP en string
"""
return [str(ip) for ip in self.network.hosts()]
async def ping(self, ip: str) -> bool:
"""
Ping une adresse IP (async)
Args:
ip: Adresse IP à pinger
Returns:
True si l'IP répond, False sinon
"""
# Détection de l'OS pour la commande ping
param = '-n' if platform.system().lower() == 'windows' else '-c'
timeout_param = '-w' if platform.system().lower() == 'windows' else '-W'
command = [
'ping',
param, str(self.ping_count),
timeout_param,
str(int(self.timeout * 1000) if platform.system().lower() == 'windows' else str(int(self.timeout))),
ip
]
try:
# Exécuter le ping de manière asynchrone
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL
)
await asyncio.wait_for(process.wait(), timeout=self.timeout + 1)
return process.returncode == 0
except (asyncio.TimeoutError, Exception):
return False
async def ping_parallel(self, ip_list: List[str], max_concurrent: int = 50) -> Dict[str, bool]:
"""
Ping multiple IPs en parallèle
Args:
ip_list: Liste des IPs à pinger
max_concurrent: Nombre maximum de pings simultanés
Returns:
Dictionnaire {ip: online_status}
"""
results = {}
semaphore = asyncio.Semaphore(max_concurrent)
async def ping_with_semaphore(ip: str):
async with semaphore:
results[ip] = await self.ping(ip)
# Lancer tous les pings en parallèle avec limite
await asyncio.gather(*[ping_with_semaphore(ip) for ip in ip_list])
return results
def get_arp_table(self) -> Dict[str, Tuple[str, str]]:
"""
Récupère la table ARP du système
Returns:
Dictionnaire {ip: (mac, vendor)}
"""
arp_data = {}
if SCAPY_AVAILABLE:
try:
# Utiliser Scapy pour ARP scan
answered, _ = srp(
Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(pdst=self.cidr),
timeout=2,
verbose=False
)
for sent, received in answered:
ip = received.psrc
mac = received.hwsrc
vendor = self._get_mac_vendor(mac)
arp_data[ip] = (mac, vendor)
except Exception as e:
print(f"Erreur ARP scan avec Scapy: {e}")
else:
# Fallback: parser la table ARP système
try:
if platform.system().lower() == 'windows':
output = subprocess.check_output(['arp', '-a'], text=True)
pattern = r'(\d+\.\d+\.\d+\.\d+)\s+([0-9a-fA-F-:]+)'
else:
output = subprocess.check_output(['arp', '-n'], text=True)
pattern = r'(\d+\.\d+\.\d+\.\d+)\s+\w+\s+([0-9a-fA-F:]+)'
matches = re.findall(pattern, output)
for ip, mac in matches:
if ip in [str(h) for h in self.network.hosts()]:
vendor = self._get_mac_vendor(mac)
arp_data[ip] = (mac, vendor)
except Exception as e:
print(f"Erreur lecture table ARP: {e}")
return arp_data
def _get_mac_vendor(self, mac: str) -> str:
"""
Lookup du fabricant depuis l'adresse MAC
Simplifié pour l'instant - peut être étendu avec une vraie DB OUI
Args:
mac: Adresse MAC
Returns:
Nom du fabricant ou "Unknown"
"""
mac_norm = re.sub(r"[^0-9A-Fa-f]", "", mac).upper()
if not mac_norm:
return "Unknown"
# Lookup OUI si fichier disponible
vendor = OuiLookup.lookup(mac_norm)
if vendor:
return vendor
# Mini DB des fabricants courants (fallback)
vendors = {
"00:0C:29": "VMware",
"00:50:56": "VMware",
"08:00:27": "VirtualBox",
"DC:A6:32": "Raspberry Pi",
"B8:27:EB": "Raspberry Pi",
}
for prefix, vendor in vendors.items():
prefix_norm = prefix.replace(":", "").upper()
if mac_norm.startswith(prefix_norm):
return vendor
return "Unknown"
async def scan_ports(self, ip: str, ports: List[int]) -> List[int]:
"""
Scan des ports TCP sur une IP
Args:
ip: Adresse IP cible
ports: Liste des ports à scanner
Returns:
Liste des ports ouverts
"""
open_ports = []
async def check_port(port: int) -> Optional[int]:
try:
# Tentative de connexion TCP
reader, writer = await asyncio.wait_for(
asyncio.open_connection(ip, port),
timeout=self.timeout
)
writer.close()
await writer.wait_closed()
return port
except:
return None
# Scanner tous les ports en parallèle
results = await asyncio.gather(*[check_port(p) for p in ports])
open_ports = [p for p in results if p is not None]
return open_ports
def get_hostname(self, ip: str) -> Optional[str]:
"""
Résolution DNS inversée pour obtenir le hostname
Args:
ip: Adresse IP
Returns:
Hostname ou None
"""
try:
hostname, _, _ = socket.gethostbyaddr(ip)
return hostname
except:
return None
def classify_ip_status(self, is_online: bool, is_known: bool) -> str:
"""
Classification de l'état d'une IP
Args:
is_online: IP en ligne
is_known: IP connue dans la config
Returns:
État: "online", "offline"
"""
return "online" if is_online else "offline"
async def full_scan(self, known_ips: Dict[str, Dict], port_list: List[int], max_concurrent: int = 50, progress_callback=None) -> Dict[str, Dict]:
"""
Scan complet du réseau selon workflow-scan.md
Args:
known_ips: Dictionnaire des IPs connues depuis config
port_list: Liste des ports à scanner
max_concurrent: Pings simultanés max
progress_callback: Fonction optionnelle pour rapporter la progression
Returns:
Dictionnaire des résultats de scan pour chaque IP
"""
results = {}
# 1. Générer liste IP du CIDR
ip_list = self.generate_ip_list()
total_ips = len(ip_list)
# 2. Ping parallélisé
ping_results = await self.ping_parallel(ip_list, max_concurrent)
# 3. ARP + MAC vendor
arp_table = self.get_arp_table()
# 4. Pour chaque IP
for index, ip in enumerate(ip_list, start=1):
is_online = ping_results.get(ip, False)
is_known = ip in known_ips
ip_data = {
"ip": ip,
"known": is_known,
"last_status": self.classify_ip_status(is_online, is_known),
"last_seen": datetime.now() if is_online else None,
"mac": None,
"vendor": None,
"hostname": None,
"open_ports": [],
}
# Ajouter infos connues
if is_known:
ip_data.update(known_ips[ip])
# Infos ARP
if ip in arp_table:
mac, vendor = arp_table[ip]
ip_data["mac"] = mac
ip_data["vendor"] = vendor
# Hostname
if is_online:
hostname = self.get_hostname(ip)
if hostname:
ip_data["hostname"] = hostname
# 5. Port scan (uniquement si online)
if is_online and port_list:
open_ports = await self.scan_ports(ip, port_list)
ip_data["open_ports"] = open_ports
results[ip] = ip_data
# Rapporter la progression
if progress_callback:
await progress_callback(index, total_ips, ip, ip_data["last_status"], is_online)
return results
class OuiLookup:
"""Lookup OUI basé sur un fichier local (oui.txt)"""
_cache = {}
_mtime = None
_path = Path("./data/oui/oui.txt")
@classmethod
def _load(cls):
if not cls._path.exists():
cls._cache = {}
cls._mtime = None
return
mtime = cls._path.stat().st_mtime
if cls._mtime == mtime and cls._cache:
return
cache = {}
try:
with cls._path.open("r", encoding="utf-8", errors="ignore") as handle:
for line in handle:
raw = line.strip()
if "(hex)" in raw:
left, right = raw.split("(hex)", 1)
prefix = re.sub(r"[^0-9A-Fa-f]", "", left).upper()[:6]
vendor = right.strip()
if len(prefix) == 6 and vendor:
cache[prefix] = vendor
except Exception:
cache = {}
cls._cache = cache
cls._mtime = mtime
print(f"[OUI] Base chargée: {len(cls._cache)} entrées depuis {cls._path}")
@classmethod
def lookup(cls, mac: str) -> Optional[str]:
if not mac:
return None
cls._load()
if not cls._cache:
return None
prefix = re.sub(r"[^0-9A-Fa-f]", "", mac).upper()[:6]
if len(prefix) != 6:
return None
return cls._cache.get(prefix)