from scapy.config import conf from manuf import MacParser from typing import List, Optional, Tuple, Dict import socket import logging import netifaces import ipaddress from concurrent.futures import ThreadPoolExecutor import time from functools import lru_cache import json from pathlib import Path from datetime import datetime, timedelta from zoneinfo import ZoneInfo import os from models.subnet import SubnetScanResult, Device from config.ports import port_config from scapy.layers.inet import IP, TCP from scapy.layers.l2 import ARP, Ether from scapy.sendrecv import srp, sr1 logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) # Подавляем логи Scapy logging.getLogger("scapy").setLevel(logging.ERROR) def normalize_mac(mac: str) -> str: """Нормализует MAC-адрес к формату XX:XX:XX:XX:XX:XX.""" if not mac: return "" mac = mac.replace(":", "").replace("-", "").upper() return ":".join(mac[i:i + 2] for i in range(0, 12, 2)) class NetworkScanner: """Класс для сканирования сети с помощью ARP-запросов.""" def __init__(self, interface: str): self.interface = interface conf.iface = interface self.arp_cache: Dict[str, str] = {} def scan(self, subnet: str) -> List[Tuple[str, str]]: try: arp = ARP(pdst=subnet) ether = Ether(dst="ff:ff:ff:ff:ff:ff") packet = ether / arp result = srp(packet, timeout=5, verbose=False, iface=self.interface)[0] devices = [(received.psrc, normalize_mac(received.hwsrc)) for sent, received in result] for ip, mac in devices: self.arp_cache[ip] = mac logger.debug(f"ARP-кэш обновлён: {len(self.arp_cache)} записей") return devices except Exception as e: logger.error(f"Ошибка ARP-сканирования подсети {subnet}: {str(e)}") return [] class PortScanner: """Класс для сканирования портов на устройстве.""" def __init__(self, interface: str, arp_cache: Dict[str, str]): self.interface = interface self.arp_cache = arp_cache def scan(self, ip: str) -> List[str]: open_ports = [] def check_port(port: int) -> Optional[str]: try: dst_mac = self.arp_cache.get(ip, "ff:ff:ff:ff:ff:ff") pkt = sr1( Ether(dst=dst_mac) / IP(dst=ip) / TCP(dport=port, flags="S"), timeout=2, verbose=False, iface=self.interface ) if pkt and pkt.haslayer(TCP) and pkt[TCP].flags == 0x12: return port_config.get_port_name(port) return None except Exception as e: logger.debug(f"Ошибка проверки порта {port} для IP {ip}: {str(e)}") return None try: max_workers = min(len(port_config.get_ports()), 20) # Уменьшено до 20 with ThreadPoolExecutor(max_workers=max_workers) as executor: results = executor.map(check_port, port_config.get_ports()) open_ports = [port for port in results if port is not None] logger.debug(f"Открытые порты для IP {ip}: {open_ports}") return open_ports except Exception as e: logger.warning(f"Ошибка при сканировании портов для IP {ip}: {str(e)}") return [] class DeviceInfoCollector: """Класс для сбора информации об устройстве.""" def __init__(self): self.mac_lookup = MacParser() try: logger.info("Обновление базы производителей MAC...") self.mac_lookup.update() logger.info("База производителей обновлена") except Exception as e: logger.warning(f"Не удалось обновить базу производителей: {str(e)}") # Проверяем наличие локальной базы manuf_db = Path(os.path.expanduser("~/.cache/manuf/manuf.txt")) if manuf_db.exists(): logger.info(f"Используется локальная база OUI: {manuf_db}") else: logger.error("Локальная база OUI отсутствует, vendors могут быть Unknown") @lru_cache(maxsize=1000) def get_vendor(self, mac: str) -> str: if not mac or mac.lower() == "ff:ff:ff:ff:ff:ff": return "Unknown" normalized_mac = normalize_mac(mac) try: vendor = self.mac_lookup.get_manuf(normalized_mac) if vendor is None: vendor = "Unknown" logger.debug(f"Vendor для MAC {normalized_mac}: {vendor}") return vendor except Exception as e: logger.debug(f"Не удалось определить производителя для MAC {normalized_mac}: {str(e)}") return "Unknown" def get_hostname(self, ip: str) -> Optional[str]: try: return socket.gethostbyaddr(ip)[0] except: return None class DeviceStorage: """Класс для хранения и управления картой устройств в JSON.""" def __init__(self, storage_path: str = "storage/devices/devices.json"): self.storage_file = Path(storage_path) self.storage_file.parent.mkdir(parents=True, exist_ok=True) self.offline_timeout = timedelta(days=7) def load_devices(self) -> Dict[str, Device]: try: if self.storage_file.exists(): with open(self.storage_file, "r", encoding="utf-8") as f: data = json.load(f) devices = {} for device_data in data.get("devices", []): device = Device(**device_data) if device.mac: devices[normalize_mac(device.mac)] = device return self._clean_old_devices(devices) return {} except Exception as e: logger.error(f"Ошибка при загрузке устройств: {str(e)}") return {} def _clean_old_devices(self, devices: Dict[str, Device]) -> Dict[str, Device]: now = datetime.now(ZoneInfo("UTC")) filtered = {} for mac, device in devices.items(): if device.status == "offline" and device.last_seen: try: last_seen = datetime.fromisoformat(device.last_seen) if now - last_seen <= self.offline_timeout: filtered[mac] = device else: logger.info(f"Удалено устройство {mac} (offline > 7 дней)") except ValueError: logger.warning(f"Неверный формат last_seen для устройства {mac}") else: filtered[mac] = device return filtered def save_devices(self, devices: List[Device]) -> None: try: for device in devices: if isinstance(device.last_seen, datetime): device.last_seen = device.last_seen.isoformat() if device.mac: device.mac = normalize_mac(device.mac) data = SubnetScanResult(devices=devices).model_dump() with open(self.storage_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) logger.info(f"Сохранено {len(devices)} устройств в {self.storage_file}") except Exception as e: logger.error(f"Ошибка при сохранении устройств: {str(e)}") class SubnetService: """Сервис для сканирования подсети и управления картой устройств.""" def __init__(self): self.interface = self._get_default_interface() self.network_scanner = NetworkScanner(self.interface) self.device_info = DeviceInfoCollector() self.port_scanner = PortScanner(self.interface, self.network_scanner.arp_cache) self.storage = DeviceStorage() def _get_default_interface(self): """Определяет основной сетевой интерфейс.""" try: gateways = netifaces.gateways() default_gateway = gateways.get("default", {}).get(netifaces.AF_INET) if default_gateway: return default_gateway[1] for iface in netifaces.interfaces(): if iface != "lo": return iface return "eth0" except Exception as e: logger.warning(f"Не удалось определить интерфейс: {str(e)}") return "eth0" def scan_subnet(self, subnet: str, ports: List[int] = None) -> SubnetScanResult: """Сканирование подсети и результаты.""" start_time = time.time() devices = [] now = datetime.now(ZoneInfo("UTC")).isoformat() try: saved_devices = self.storage.load_devices() local_device = self._get_local_device(subnet) if local_device: local_device.status = "online" local_device.last_seen = now devices.append(local_device) if local_device.mac: saved_devices[normalize_mac(local_device.mac)] = local_device discovered_devices = self.network_scanner.scan(subnet) with ThreadPoolExecutor(max_workers=20) as executor: # Уменьшено до 20 def process_device(ip_mac: Tuple[str, str]) -> Optional[Device]: ip, mac = ip_mac mac = normalize_mac(mac) if local_device and ip == local_device.ip: return None existing_device = saved_devices.get(mac) vendor = self.device_info.get_vendor(mac) if vendor == "Unknown" and existing_device and existing_device.vendor != "Unknown": vendor = existing_device.vendor logger.debug(f"Использован сохранённый vendor для MAC {mac}: {vendor}") return Device( ip=ip, mac=mac, hostname=self.device_info.get_hostname(ip) or ( existing_device.hostname if existing_device else None), vendor=vendor, open_ports=self.port_scanner.scan(ip) or [], status="online", last_seen=now ) device_results = executor.map(process_device, discovered_devices) devices.extend([d for d in device_results if d is not None]) active_macs = {d.mac for d in devices if d.mac} for mac, device in saved_devices.items(): if mac not in active_macs: device.status = "offline" devices.append(device) devices.sort(key=lambda x: int(ipaddress.ip_address(x.ip))) for device in devices: if device.mac: saved_devices[device.mac] = device self.storage.save_devices(devices) logger.info( f"Сканирование подсети {subnet} завершено за {time.time() - start_time:.2f} сек, " f"найдено {len(devices)} устройств (активных: {len(active_macs)})" ) return SubnetScanResult(devices=devices) except Exception as e: logger.error(f"Ошибка при сканировании подсети {subnet}: {str(e)}") return SubnetScanResult(devices=devices) def _get_local_device(self, subnet: str) -> Optional[Device]: """Получает информацию о локальном устройстве.""" now = datetime.now(ZoneInfo("UTC")).isoformat() try: subnet_network = ipaddress.ip_network(subnet, strict=False) for iface in netifaces.interfaces(): if iface == "lo": continue addrs = netifaces.ifaddresses(iface) if netifaces.AF_INET in addrs and netifaces.AF_LINK in addrs: ip = addrs[netifaces.AF_INET][0]["addr"] if ipaddress.ip_address(ip) in subnet_network: mac = normalize_mac(addrs[netifaces.AF_LINK][0]["addr"]) vendor = self.device_info.get_vendor(mac) hostname = self.device_info.get_hostname(ip) open_ports = self.port_scanner.scan(ip) logger.debug(f"Локальное устройство: IP={ip}, MAC={mac}, Vendor={vendor}, Ports={open_ports}") return Device( ip=ip, mac=mac, hostname=hostname, vendor=vendor, open_ports=open_ports, status="online", last_seen=now ) logger.warning(f"Локальное устройство не найдено в подсети {subnet}") return None except Exception as e: logger.warning(f"Не удалось получить локальное устройство: {str(e)}") return None