network_scanner.py•46.2 kB
from typing import Dict, List, Optional, Any, Set, Tuple, Union
from dataclasses import dataclass, field
from enum import Enum
import logging
import sys
import os
import json
import asyncio
import ipaddress
import socket
import subprocess
import re
import concurrent.futures
from datetime import datetime, timedelta
import aiohttp
import platform
# 引入相同的日志格式化处理
class JsonFormatter(logging.Formatter):
def __init__(self, fmt=None, datefmt=None, style='%'):
super().__init__(fmt, datefmt, style)
def format(self, record):
log_record = super().format(record)
return log_record.encode('utf-8', errors='replace').decode('utf-8')
# 配置日志格式
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(stream=sys.stdout)
]
)
# 设置所有处理器使用UTF-8编码格式化
for handler in logging.root.handlers:
if isinstance(handler, logging.StreamHandler):
handler.setFormatter(JsonFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
logger = logging.getLogger("network_scanner")
from network_devices import DeviceManager, NetworkDevice, DeviceVendor, DeviceType, DeviceStatus
class ScanMethod(Enum):
"""扫描方法枚举"""
PING = "ping"
TCP_CONNECT = "tcp_connect"
SNMP = "snmp"
HTTP = "http"
SSH_BANNER = "ssh_banner"
class DeviceDiscoveryMethod(Enum):
"""设备发现方法枚举"""
PING_SWEEP = "ping_sweep"
ARP_TABLE = "arp_table"
SNMP_WALK = "snmp_walk"
PORT_SCAN = "port_scan"
CDP_LLDP = "cdp_lldp"
@dataclass
class ScanResult:
"""单个IP扫描结果"""
ip_address: str
is_alive: bool = False
response_time: Optional[float] = None
hostname: Optional[str] = None
mac_address: Optional[str] = None
vendor: Optional[str] = None
device_type: Optional[str] = None
open_ports: List[int] = field(default_factory=list)
services: Dict[int, str] = field(default_factory=dict)
snmp_info: Dict[str, Any] = field(default_factory=dict)
os_info: Optional[str] = None
discovered_at: datetime = field(default_factory=datetime.now)
discovery_method: Set[DeviceDiscoveryMethod] = field(default_factory=set)
def to_dict(self) -> Dict[str, Any]:
return {
"ip_address": self.ip_address,
"is_alive": self.is_alive,
"response_time": self.response_time,
"hostname": self.hostname,
"mac_address": self.mac_address,
"vendor": self.vendor,
"device_type": self.device_type,
"open_ports": self.open_ports,
"services": self.services,
"snmp_info": self.snmp_info,
"os_info": self.os_info,
"discovered_at": self.discovered_at.isoformat(),
"discovery_method": [method.value for method in self.discovery_method]
}
@dataclass
class ScanConfiguration:
"""扫描配置"""
timeout: float = 3.0
max_concurrent: int = 50
ping_enabled: bool = True
port_scan_enabled: bool = True
snmp_enabled: bool = True
http_check_enabled: bool = True
ssh_banner_enabled: bool = True
common_ports: List[int] = field(default_factory=lambda: [22, 23, 80, 161, 443, 8080, 8443])
snmp_communities: List[str] = field(default_factory=lambda: ["public", "private"])
class NetworkScanner:
"""网络扫描器"""
def __init__(self, device_manager: DeviceManager):
self.device_manager = device_manager
self.data_dir = "data"
self.scan_results_file = os.path.join(self.data_dir, "scan_results.json")
# 创建数据目录
os.makedirs(self.data_dir, exist_ok=True)
# 加载历史扫描结果
self.scan_results: Dict[str, ScanResult] = {}
self.load_scan_results()
# 厂商MAC地址前缀映射(OUI - Organizationally Unique Identifier)
self.vendor_oui_map = {
"00:00:0C": "Cisco",
"00:01:42": "Cisco",
"00:01:43": "Cisco",
"00:01:96": "Cisco",
"00:01:97": "Cisco",
"00:02:16": "Cisco",
"00:02:17": "Cisco",
"00:02:3D": "Cisco",
"00:02:4A": "Cisco",
"00:02:4B": "Cisco",
"00:02:B9": "Cisco",
"00:02:BA": "Cisco",
"00:02:FC": "Cisco",
"00:02:FD": "Cisco",
"00:03:6B": "Cisco",
"00:03:6C": "Cisco",
"00:03:E3": "Cisco",
"00:03:FD": "Cisco",
"00:04:27": "Cisco",
"00:04:28": "Cisco",
"00:04:4D": "Cisco",
"00:04:6D": "Cisco",
"00:04:9A": "Cisco",
"00:04:C0": "Cisco",
"00:04:C1": "Cisco",
"00:04:DD": "Cisco",
"00:05:00": "Cisco",
"00:05:01": "Cisco",
"00:05:31": "Cisco",
"00:05:32": "Cisco",
"00:05:5E": "Cisco",
"00:05:73": "Cisco",
"00:05:74": "Cisco",
"00:05:9A": "Cisco",
"00:05:DC": "Cisco",
"00:05:DD": "Cisco",
"00:06:28": "Cisco",
"00:06:2A": "Cisco",
"00:06:52": "Cisco",
"00:06:53": "Cisco",
"00:06:7C": "Cisco",
"00:06:C1": "Cisco",
"00:06:D6": "Cisco",
"00:06:D7": "Cisco",
"00:06:F6": "Cisco",
"00:07:0D": "Cisco",
"00:07:0E": "Cisco",
"00:07:4F": "Cisco",
"00:07:50": "Cisco",
"00:07:84": "Cisco",
"00:07:85": "Cisco",
"00:07:B3": "Cisco",
"00:07:B4": "Cisco",
"00:07:EB": "Cisco",
"00:07:EC": "Cisco",
"00:08:20": "Cisco",
"00:08:21": "Cisco",
"00:08:30": "Cisco",
"00:08:31": "Cisco",
"00:08:7C": "Cisco",
"00:08:7D": "Cisco",
"00:08:A3": "Cisco",
"00:08:A4": "Cisco",
"00:08:C2": "Cisco",
"00:08:E2": "Cisco",
"00:08:E3": "Cisco",
"00:09:11": "Cisco",
"00:09:12": "Cisco",
"00:09:43": "Cisco",
"00:09:44": "Cisco",
"00:09:7B": "Cisco",
"00:09:7C": "Cisco",
"00:09:B6": "Cisco",
"00:09:B7": "Cisco",
"00:09:E8": "Cisco",
"00:09:E9": "Cisco",
"00:0A:41": "Cisco",
"00:0A:42": "Cisco",
"00:0A:8A": "Cisco",
"00:0A:8B": "Cisco",
"00:0A:B7": "Cisco",
"00:0A:B8": "Cisco",
"00:0A:F3": "Cisco",
"00:0A:F4": "Cisco",
"00:0B:45": "Cisco",
"00:0B:46": "Cisco",
"00:0B:5F": "Cisco",
"00:0B:60": "Cisco",
"00:0B:85": "Cisco",
"00:0B:BE": "Cisco",
"00:0B:BF": "Cisco",
"00:0B:FC": "Cisco",
"00:0B:FD": "Cisco",
"00:0C:30": "Cisco",
"00:0C:31": "Cisco",
"00:0C:41": "Cisco",
"00:0C:85": "Cisco",
"00:0C:86": "Cisco",
"00:0C:CE": "Cisco",
"00:0C:CF": "Cisco",
"00:0D:28": "Cisco",
"00:0D:29": "Cisco",
"00:0D:65": "Cisco",
"00:0D:66": "Cisco",
"00:0D:BC": "Cisco",
"00:0D:BD": "Cisco",
"00:0D:EC": "Cisco",
"00:0D:ED": "Cisco",
"00:0E:08": "Cisco",
"00:0E:38": "Cisco",
"00:0E:39": "Cisco",
"00:0E:83": "Cisco",
"00:0E:84": "Cisco",
"00:0E:D6": "Cisco",
"00:0E:D7": "Cisco",
"00:0F:23": "Cisco",
"00:0F:24": "Cisco",
"00:0F:34": "Cisco",
"00:0F:35": "Cisco",
"00:0F:66": "Cisco",
"00:0F:8F": "Cisco",
"00:0F:90": "Cisco",
"00:0F:F7": "Cisco",
"00:0F:F8": "Cisco",
"00:10:07": "Cisco",
"00:10:11": "Cisco",
"00:10:29": "Cisco",
"00:10:2F": "Cisco",
"00:10:54": "Cisco",
"00:10:79": "Cisco",
"00:10:7B": "Cisco",
"00:10:A6": "Cisco",
"00:10:F6": "Cisco",
"00:11:20": "Cisco",
"00:11:21": "Cisco",
"00:11:5C": "Cisco",
"00:11:5D": "Cisco",
"00:11:92": "Cisco",
"00:11:93": "Cisco",
"00:11:BB": "Cisco",
"00:11:BC": "Cisco",
"00:12:00": "Cisco",
"00:12:01": "Cisco",
"00:12:17": "Cisco",
"00:12:43": "Cisco",
"00:12:44": "Cisco",
"00:12:7F": "Cisco",
"00:12:80": "Cisco",
"00:12:D9": "Cisco",
"00:12:DA": "Cisco",
"00:13:19": "Cisco",
"00:13:1A": "Cisco",
"00:13:5F": "Cisco",
"00:13:60": "Cisco",
"00:13:7F": "Cisco",
"00:13:80": "Cisco",
"00:13:C3": "Cisco",
"00:13:C4": "Cisco",
"00:14:1B": "Cisco",
"00:14:1C": "Cisco",
"00:14:69": "Cisco",
"00:14:6A": "Cisco",
"00:14:A8": "Cisco",
"00:14:A9": "Cisco",
"00:14:BF": "Cisco",
"00:14:F1": "Cisco",
"00:14:F2": "Cisco",
"00:15:2B": "Cisco",
"00:15:2C": "Cisco",
"00:15:62": "Cisco",
"00:15:63": "Cisco",
"00:15:C6": "Cisco",
"00:15:C7": "Cisco",
"00:15:F9": "Cisco",
"00:15:FA": "Cisco",
"00:16:46": "Cisco",
"00:16:47": "Cisco",
"00:16:9C": "Cisco",
"00:16:9D": "Cisco",
"00:16:B6": "Cisco",
"00:16:C7": "Cisco",
"00:16:C8": "Cisco",
"00:17:0E": "Cisco",
"00:17:0F": "Cisco",
"00:17:33": "Cisco",
"00:17:34": "Cisco",
"00:17:59": "Cisco",
"00:17:5A": "Cisco",
"00:17:94": "Cisco",
"00:17:95": "Cisco",
"00:17:DF": "Cisco",
"00:17:E0": "Cisco",
"00:18:18": "Cisco",
"00:18:19": "Cisco",
"00:18:39": "Cisco",
"00:18:68": "Cisco",
"00:18:73": "Cisco",
"00:18:74": "Cisco",
"00:18:B9": "Cisco",
"00:18:BA": "Cisco",
"00:19:06": "Cisco",
"00:19:07": "Cisco",
"00:19:2F": "Cisco",
"00:19:30": "Cisco",
"00:19:47": "Cisco",
"00:19:55": "Cisco",
"00:19:56": "Cisco",
"00:19:A9": "Cisco",
"00:19:AA": "Cisco",
"00:19:E7": "Cisco",
"00:19:E8": "Cisco",
"00:1A:2F": "Cisco",
"00:1A:30": "Cisco",
"00:1A:6C": "Cisco",
"00:1A:6D": "Cisco",
"00:1A:A1": "Cisco",
"00:1A:A2": "Cisco",
"00:1A:E2": "Cisco",
"00:1A:E3": "Cisco",
"00:1B:0C": "Cisco",
"00:1B:0D": "Cisco",
"00:1B:2A": "Cisco",
"00:1B:2B": "Cisco",
"00:1B:53": "Cisco",
"00:1B:54": "Cisco",
"00:1B:67": "Cisco",
"00:1B:8F": "Cisco",
"00:1B:90": "Cisco",
"00:1B:D4": "Cisco",
"00:1B:D5": "Cisco",
"00:1C:0E": "Cisco",
"00:1C:0F": "Cisco",
"00:1C:57": "Cisco",
"00:1C:58": "Cisco",
"00:1C:B0": "Cisco",
"00:1C:B1": "Cisco",
"00:1C:F6": "Cisco",
"00:1C:F9": "Cisco",
"00:1D:45": "Cisco",
"00:1D:46": "Cisco",
"00:1D:70": "Cisco",
"00:1D:71": "Cisco",
"00:1D:A1": "Cisco",
"00:1D:A2": "Cisco",
"00:1D:E5": "Cisco",
"00:1D:E6": "Cisco",
"00:1E:13": "Cisco",
"00:1E:14": "Cisco",
"00:1E:49": "Cisco",
"00:1E:4A": "Cisco",
"00:1E:6B": "Cisco",
"00:1E:79": "Cisco",
"00:1E:7A": "Cisco",
"00:1E:BD": "Cisco",
"00:1E:BE": "Cisco",
"00:1E:F6": "Cisco",
"00:1E:F7": "Cisco",
"00:1F:26": "Cisco",
"00:1F:27": "Cisco",
"00:1F:6C": "Cisco",
"00:1F:6D": "Cisco",
"00:1F:9D": "Cisco",
"00:1F:9E": "Cisco",
"00:1F:C9": "Cisco",
"00:1F:CA": "Cisco",
"00:20:35": "Cisco",
"00:20:36": "Cisco",
"00:20:BA": "Cisco",
"00:20:BB": "Cisco",
"00:21:1B": "Cisco",
"00:21:1C": "Cisco",
"00:21:29": "Cisco",
"00:21:55": "Cisco",
"00:21:56": "Cisco",
"00:21:A0": "Cisco",
"00:21:A1": "Cisco",
"00:21:BE": "Cisco",
"00:21:D7": "Cisco",
"00:21:D8": "Cisco",
"00:22:0C": "Cisco",
"00:22:0D": "Cisco",
"00:22:55": "Cisco",
"00:22:56": "Cisco",
"00:22:6B": "Cisco",
"00:22:90": "Cisco",
"00:22:91": "Cisco",
"00:22:BD": "Cisco",
"00:22:BE": "Cisco",
"00:23:04": "Cisco",
"00:23:05": "Cisco",
"00:23:33": "Cisco",
"00:23:34": "Cisco",
"00:23:5D": "Cisco",
"00:23:5E": "Cisco",
"00:23:AB": "Cisco",
"00:23:AC": "Cisco",
"00:23:BE": "Cisco",
"00:23:EA": "Cisco",
"00:23:EB": "Cisco",
"00:24:13": "Cisco",
"00:24:14": "Cisco",
"00:24:50": "Cisco",
"00:24:51": "Cisco",
"00:24:C3": "Cisco",
"00:24:C4": "Cisco",
"00:24:F7": "Cisco",
"00:24:F9": "Cisco",
"00:25:2E": "Cisco",
"00:25:45": "Cisco",
"00:25:46": "Cisco",
"00:25:83": "Cisco",
"00:25:84": "Cisco",
"00:25:B4": "Cisco",
"00:25:B5": "Cisco",
"00:26:0A": "Cisco",
"00:26:0B": "Cisco",
"00:26:51": "Cisco",
"00:26:52": "Cisco",
"00:26:98": "Cisco",
"00:26:99": "Cisco",
"00:26:CA": "Cisco",
"00:26:CB": "Cisco",
"00:27:0C": "Cisco",
"00:27:0D": "Cisco",
"00:27:23": "Cisco",
"00:27:24": "Cisco",
"00:40:96": "Cisco",
"00:50:0B": "Cisco",
"00:50:14": "Cisco",
"00:50:50": "Cisco",
"00:50:53": "Cisco",
"00:50:54": "Cisco",
"00:50:73": "Cisco",
"00:50:80": "Cisco",
"00:50:A2": "Cisco",
"00:50:BD": "Cisco",
"00:50:D1": "Cisco",
"00:50:E2": "Cisco",
"00:50:F0": "Cisco",
"00:60:2F": "Cisco",
"00:60:3E": "Cisco",
"00:60:47": "Cisco",
"00:60:5C": "Cisco",
"00:60:70": "Cisco",
"00:60:83": "Cisco",
"00:90:21": "Cisco",
"00:90:2B": "Cisco",
"00:90:86": "Cisco",
"00:90:92": "Cisco",
"00:90:A6": "Cisco",
"00:90:AB": "Cisco",
"00:90:B1": "Cisco",
"00:90:BF": "Cisco",
"00:90:F2": "Cisco",
"00:A0:C9": "Cisco",
"00:B0:64": "Cisco",
"00:C0:1D": "Cisco",
"00:D0:06": "Cisco",
"00:D0:58": "Cisco",
"00:D0:79": "Cisco",
"00:D0:90": "Cisco",
"00:D0:97": "Cisco",
"00:D0:BA": "Cisco",
"00:D0:BB": "Cisco",
"00:D0:BC": "Cisco",
"00:D0:C0": "Cisco",
"00:D0:C4": "Cisco",
"00:D0:FF": "Cisco",
"00:E0:14": "Cisco",
"00:E0:1E": "Cisco",
"00:E0:34": "Cisco",
"00:E0:4F": "Cisco",
"00:E0:8F": "Cisco",
"00:E0:A3": "Cisco",
"00:E0:B0": "Cisco",
"00:E0:F7": "Cisco",
"00:E0:F9": "Cisco",
"00:E0:FE": "Cisco",
"04:2C:6A": "Cisco",
"04:6C:59": "Cisco",
"04:C5:A4": "Cisco",
"04:DA:D2": "Cisco",
"04:FE:7F": "Cisco",
"08:17:35": "Cisco",
"08:1F:F3": "Cisco",
"08:7A:4C": "Cisco",
"08:96:AD": "Cisco",
"08:CC:68": "Cisco",
"0C:27:24": "Cisco",
"0C:68:03": "Cisco",
"0C:75:BD": "Cisco",
"0C:85:25": "Cisco",
"0C:D9:96": "Cisco",
"0C:F5:A4": "Cisco",
"10:8C:CF": "Cisco",
"10:BD:18": "Cisco",
"14:B8:6F": "Cisco",
"14:DA:E9": "Cisco",
"14:F6:D8": "Cisco",
"18:8B:9D": "Cisco",
"18:8B:45": "Cisco",
"18:9C:5D": "Cisco",
"18:EF:63": "Cisco",
"1C:17:D3": "Cisco",
"1C:6A:7A": "Cisco",
"1C:DE:A7": "Cisco",
"1C:E6:C7": "Cisco",
"1C:E8:5D": "Cisco",
"20:37:06": "Cisco",
"20:3A:07": "Cisco",
"20:4C:03": "Cisco",
"20:BB:C0": "Cisco",
"24:01:C7": "Cisco",
"24:B6:57": "Cisco",
"24:E9:B3": "Cisco",
"28:0D:FC": "Cisco",
"28:39:5E": "Cisco",
"28:6F:7F": "Cisco",
"28:94:0F": "Cisco",
"2C:36:F8": "Cisco",
"2C:3E:CF": "Cisco",
"2C:54:2D": "Cisco",
"2C:5A:0F": "Cisco",
"30:37:A6": "Cisco",
"30:E4:DB": "Cisco",
"34:A8:4E": "Cisco",
"34:DB:FD": "Cisco",
"38:C8:5C": "Cisco",
"3C:CE:73": "Cisco",
"40:55:39": "Cisco",
"44:58:29": "Cisco",
"44:AD:D9": "Cisco",
"44:E0:8E": "Cisco",
"48:F8:B3": "Cisco",
"4C:4E:35": "Cisco",
"50:06:04": "Cisco",
"50:17:FF": "Cisco",
"50:3D:E5": "Cisco",
"50:57:A8": "Cisco",
"50:87:89": "Cisco",
"54:7F:EE": "Cisco",
"54:78:1A": "Cisco",
"58:6D:8F": "Cisco",
"58:97:1E": "Cisco",
"58:AC:78": "Cisco",
"58:BF:EA": "Cisco",
"5C:50:15": "Cisco",
"5C:83:8F": "Cisco",
"60:73:5C": "Cisco",
"64:00:F1": "Cisco",
"64:16:8D": "Cisco",
"64:A0:E7": "Cisco",
"64:D1:54": "Cisco",
"68:BD:AB": "Cisco",
"68:EF:BD": "Cisco",
"6C:20:56": "Cisco",
"6C:41:6A": "Cisco",
"6C:FA:89": "Cisco",
"70:0D:B9": "Cisco",
"70:10:5C": "Cisco",
"70:81:05": "Cisco",
"70:CA:9B": "Cisco",
"74:26:AC": "Cisco",
"74:A0:2F": "Cisco",
"78:9A:18": "Cisco",
"78:BA:F9": "Cisco",
"7C:0E:CE": "Cisco",
"7C:69:F6": "Cisco",
"7C:95:F3": "Cisco",
"80:E0:1D": "Cisco",
"84:3D:C6": "Cisco",
"84:78:AC": "Cisco",
"84:B5:17": "Cisco",
"84:B8:02": "Cisco",
"88:43:E1": "Cisco",
"88:90:8D": "Cisco",
"88:F0:31": "Cisco",
"8C:60:4F": "Cisco",
"8C:B6:4F": "Cisco",
"90:E2:BA": "Cisco",
"94:D4:69": "Cisco",
"98:FC:11": "Cisco",
"9C:37:F4": "Cisco",
"9C:4E:20": "Cisco",
"A0:E0:AF": "Cisco",
"A0:F8:49": "Cisco",
"A4:0C:C3": "Cisco",
"A4:4C:11": "Cisco",
"A4:6C:2A": "Cisco",
"A4:93:4C": "Cisco",
"A8:9D:21": "Cisco",
"AC:4A:67": "Cisco",
"B0:7D:47": "Cisco",
"B4:14:89": "Cisco",
"B4:A4:E3": "Cisco",
"B8:38:61": "Cisco",
"B8:62:1F": "Cisco",
"BC:16:65": "Cisco",
"BC:67:1C": "Cisco",
"C0:62:6B": "Cisco",
"C0:67:AF": "Cisco",
"C4:14:3C": "Cisco",
"C4:64:13": "Cisco",
"C4:71:54": "Cisco",
"C8:4C:75": "Cisco",
"C8:9C:1D": "Cisco",
"CC:16:7E": "Cisco",
"CC:46:D6": "Cisco",
"CC:EF:48": "Cisco",
"D0:72:DC": "Cisco",
"D0:D0:FD": "Cisco",
"D4:8C:B5": "Cisco",
"D4:A0:2A": "Cisco",
"D8:24:BD": "Cisco",
"D8:B1:90": "Cisco",
"DC:7B:94": "Cisco",
"E0:5F:B9": "Cisco",
"E4:48:C7": "Cisco",
"E4:AA:5D": "Cisco",
"E4:C7:22": "Cisco",
"E8:65:49": "Cisco",
"E8:B7:48": "Cisco",
"EC:1D:8B": "Cisco",
"EC:44:76": "Cisco",
"EC:BD:1D": "Cisco",
"F0:25:72": "Cisco",
"F0:29:29": "Cisco",
"F0:7D:68": "Cisco",
"F4:CF:E2": "Cisco",
"F8:0B:CB": "Cisco",
"F8:4F:57": "Cisco",
"F8:66:F2": "Cisco",
"F8:C2:88": "Cisco",
"FC:99:47": "Cisco",
# 华为 OUI 前缀
"00:1E:10": "Huawei",
"00:25:9E": "Huawei",
"00:46:A7": "Huawei",
"00:E0:FC": "Huawei",
"10:47:80": "Huawei",
"14:5A:05": "Huawei",
"18:66:DA": "Huawei",
"1C:61:B4": "Huawei",
"20:F9:E0": "Huawei",
"28:6E:D4": "Huawei",
"2C:AB:00": "Huawei",
"34:29:8F": "Huawei",
"34:6B:D3": "Huawei",
"34:CE:00": "Huawei",
"38:90:A5": "Huawei",
"3C:DF:A9": "Huawei",
"40:B0:34": "Huawei",
"44:31:92": "Huawei",
"48:46:FB": "Huawei",
"4C:54:99": "Huawei",
"50:3D:E5": "Huawei",
"54:89:98": "Huawei",
"5C:63:BF": "Huawei",
"60:DE:44": "Huawei",
"68:BD:AB": "Huawei",
"6C:92:BF": "Huawei",
"70:72:3C": "Huawei",
"74:51:BA": "Huawei",
"78:11:DC": "Huawei",
"7C:A2:3E": "Huawei",
"80:89:17": "Huawei",
"84:A9:3E": "Huawei",
"88:CF:98": "Huawei",
"8C:BE:BE": "Huawei",
"90:2B:34": "Huawei",
"94:23:3C": "Huawei",
"A0:B4:A5": "Huawei",
"A4:5E:60": "Huawei",
"A8:4C:A6": "Huawei",
"AC:85:3D": "Huawei",
"B0:5A:DA": "Huawei",
"B4:3A:28": "Huawei",
"B8:50:01": "Huawei",
"BC:3F:8F": "Huawei",
"BC:76:70": "Huawei",
"C0:A5:DD": "Huawei",
"C4:F0:81": "Huawei",
"C8:9C:1D": "Huawei",
"CC:2D:21": "Huawei",
"D0:54:2D": "Huawei",
"D4:D7:48": "Huawei",
"D8:49:2F": "Huawei",
"DC:D2:FC": "Huawei",
"E0:19:1D": "Huawei",
"E4:3E:D7": "Huawei",
"E8:4E:84": "Huawei",
"EC:23:3D": "Huawei",
"F0:92:1C": "Huawei",
"F4:28:53": "Huawei",
"F8:E7:1E": "Huawei",
"FC:48:EF": "Huawei",
# H3C OUI 前缀
"00:01:A7": "H3C",
"00:13:AC": "H3C",
"00:1E:C9": "H3C",
"00:22:A1": "H3C",
"00:24:A1": "H3C",
"04:C5:A4": "H3C",
"08:00:09": "H3C",
"0C:45:BA": "H3C",
"18:A9:05": "H3C",
"1C:B7:2C": "H3C",
"20:89:84": "H3C",
"24:E9:B3": "H3C",
"28:6E:D4": "H3C",
"2C:AB:00": "H3C",
"30:89:4A": "H3C",
"3C:52:82": "H3C",
"48:7B:6B": "H3C",
"4C:60:DE": "H3C",
"58:69:6C": "H3C",
"6C:50:4D": "H3C",
"70:F9:6D": "H3C",
"78:AC:C0": "H3C",
"7C:A2:3E": "H3C",
"80:71:1F": "H3C",
"84:A9:C4": "H3C",
"94:75:2A": "H3C",
"A0:1D:48": "H3C",
"A4:5E:60": "H3C",
"A8:40:25": "H3C",
"B0:18:32": "H3C",
"B4:99:BA": "H3C",
"BC:54:36": "H3C",
"C0:A5:DD": "H3C",
"C8:9C:1D": "H3C",
"D0:27:88": "H3C",
"D4:6D:7D": "H3C",
"E0:D9:E3": "H3C",
"E4:AF:A1": "H3C",
"EC:38:8F": "H3C",
"F0:7D:68": "H3C",
"F4:E2:C6": "H3C",
"F8:63:3F": "H3C",
# Juniper OUI 前缀
"00:05:85": "Juniper",
"00:10:DB": "Juniper",
"00:12:1E": "Juniper",
"00:17:CB": "Juniper",
"00:19:E2": "Juniper",
"00:1B:C0": "Juniper",
"00:1D:B5": "Juniper",
"00:1F:12": "Juniper",
"00:21:59": "Juniper",
"00:23:9C": "Juniper",
"00:26:88": "Juniper",
"00:90:69": "Juniper",
"02:05:85": "Juniper",
"08:81:F4": "Juniper",
"0C:86:10": "Juniper",
"10:0E:7E": "Juniper",
"14:4F:8A": "Juniper",
"2C:21:72": "Juniper",
"2C:6B:F5": "Juniper",
"3C:94:D5": "Juniper",
"40:A6:77": "Juniper",
"44:F4:77": "Juniper",
"5C:45:27": "Juniper",
"64:64:9B": "Juniper",
"78:19:F7": "Juniper",
"78:FE:3D": "Juniper",
"80:71:1F": "Juniper",
"84:18:88": "Juniper",
"9C:CC:83": "Juniper",
"A8:D0:E5": "Juniper",
"B0:C6:9A": "Juniper",
"DC:38:E1": "Juniper",
"EC:3E:F7": "Juniper",
"F0:1C:2D": "Juniper",
"F4:A7:39": "Juniper",
"F4:CC:55": "Juniper"
}
# 常见网络设备端口映射
self.device_port_signatures = {
22: "SSH/Network Device",
23: "Telnet/Network Device",
80: "HTTP/Web Management",
161: "SNMP/Network Device",
443: "HTTPS/Web Management",
830: "NETCONF",
8080: "HTTP Alt/Web Management",
8443: "HTTPS Alt/Web Management"
}
def save_scan_results(self):
"""保存扫描结果到文件"""
try:
results_dict = {
ip: result.to_dict() for ip, result in self.scan_results.items()
}
with open(self.scan_results_file, 'w', encoding='utf-8') as f:
json.dump(results_dict, f, ensure_ascii=False, indent=2)
logger.info(f"扫描结果已保存到: {self.scan_results_file}")
except Exception as e:
logger.error(f"保存扫描结果失败: {e}")
def load_scan_results(self):
"""从文件加载扫描结果"""
if not os.path.exists(self.scan_results_file):
logger.info("扫描结果文件不存在,将创建新的")
return
try:
with open(self.scan_results_file, 'r', encoding='utf-8') as f:
data = json.load(f)
for ip, result_data in data.items():
result = ScanResult(
ip_address=result_data["ip_address"],
is_alive=result_data["is_alive"],
response_time=result_data.get("response_time"),
hostname=result_data.get("hostname"),
mac_address=result_data.get("mac_address"),
vendor=result_data.get("vendor"),
device_type=result_data.get("device_type"),
open_ports=result_data.get("open_ports", []),
services=result_data.get("services", {}),
snmp_info=result_data.get("snmp_info", {}),
os_info=result_data.get("os_info"),
discovered_at=datetime.fromisoformat(result_data["discovered_at"]),
discovery_method=set(DeviceDiscoveryMethod(method) for method in result_data.get("discovery_method", []))
)
self.scan_results[ip] = result
logger.info(f"已加载{len(self.scan_results)}个扫描结果")
except Exception as e:
logger.error(f"加载扫描结果失败: {e}")
async def ping_host(self, ip: str, timeout: float = 3.0) -> Tuple[bool, Optional[float]]:
"""异步ping主机"""
try:
# 根据操作系统选择ping命令
if platform.system().lower() == "windows":
cmd = ["ping", "-n", "1", "-w", str(int(timeout * 1000)), ip]
else:
cmd = ["ping", "-c", "1", "-W", str(int(timeout)), ip]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=timeout + 1
)
if process.returncode == 0:
# 解析ping时间
output = stdout.decode()
if platform.system().lower() == "windows":
# Windows ping输出解析
time_match = re.search(r'时间[<=](\d+)ms|time[<=](\d+)ms', output, re.IGNORECASE)
if time_match:
time_value = time_match.group(1) or time_match.group(2)
return True, float(time_value)
else:
# Linux/Unix ping输出解析
time_match = re.search(r'time=(\d+\.?\d*)ms', output)
if time_match:
return True, float(time_match.group(1))
return True, None
else:
return False, None
except asyncio.TimeoutError:
return False, None
except Exception as e:
logger.debug(f"Ping {ip} 失败: {e}")
return False, None
async def scan_port(self, ip: str, port: int, timeout: float = 3.0) -> bool:
"""异步扫描单个端口"""
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(ip, port),
timeout=timeout
)
writer.close()
await writer.wait_closed()
return True
except:
return False
async def scan_ports(self, ip: str, ports: List[int], timeout: float = 3.0) -> List[int]:
"""异步扫描多个端口"""
tasks = [self.scan_port(ip, port, timeout) for port in ports]
results = await asyncio.gather(*tasks, return_exceptions=True)
open_ports = []
for port, result in zip(ports, results):
if isinstance(result, bool) and result:
open_ports.append(port)
return open_ports
async def get_hostname(self, ip: str) -> Optional[str]:
"""获取主机名"""
try:
hostname = socket.gethostbyaddr(ip)[0]
return hostname
except:
return None
def get_vendor_by_mac(self, mac_address: str) -> Optional[str]:
"""根据MAC地址前缀获取厂商信息"""
if not mac_address:
return None
# 标准化MAC地址格式
mac_upper = mac_address.upper().replace("-", ":").replace(".", ":")
# 获取前6位字符(OUI部分)
if len(mac_upper) >= 8:
oui = mac_upper[:8] # XX:XX:XX 格式
return self.vendor_oui_map.get(oui)
return None
def infer_device_type(self, scan_result: ScanResult) -> DeviceType:
"""根据扫描结果推断设备类型"""
open_ports = scan_result.open_ports
vendor = scan_result.vendor or ""
hostname = scan_result.hostname or ""
# 基于端口判断
if 161 in open_ports: # SNMP端口
if 22 in open_ports or 23 in open_ports:
# 同时有SSH/Telnet和SNMP,可能是网络设备
if any(keyword in hostname.lower() for keyword in ["switch", "sw", "router", "rt", "gw", "gateway"]):
if "switch" in hostname.lower() or "sw" in hostname.lower():
return DeviceType.SWITCH
elif "router" in hostname.lower() or "rt" in hostname.lower() or "gw" in hostname.lower():
return DeviceType.ROUTER
elif vendor.lower() in ["cisco", "huawei", "h3c", "juniper"]:
return DeviceType.SWITCH # 默认网络设备厂商为交换机
# 基于厂商判断
if vendor.lower() == "cisco":
return DeviceType.SWITCH
elif vendor.lower() in ["huawei", "h3c"]:
return DeviceType.SWITCH
elif vendor.lower() == "juniper":
return DeviceType.ROUTER
# 基于主机名判断
hostname_lower = hostname.lower()
if any(keyword in hostname_lower for keyword in ["switch", "sw"]):
return DeviceType.SWITCH
elif any(keyword in hostname_lower for keyword in ["router", "rt", "gw", "gateway"]):
return DeviceType.ROUTER
elif any(keyword in hostname_lower for keyword in ["firewall", "fw", "asa", "pix"]):
return DeviceType.FIREWALL
elif any(keyword in hostname_lower for keyword in ["ap", "wireless", "wifi"]):
return DeviceType.ACCESS_POINT
# 默认返回其他类型
return DeviceType.OTHER
def infer_device_vendor(self, scan_result: ScanResult) -> DeviceVendor:
"""根据扫描结果推断设备厂商"""
vendor = scan_result.vendor or ""
hostname = scan_result.hostname or ""
# 优先使用MAC地址推断的厂商
if vendor:
vendor_lower = vendor.lower()
if "cisco" in vendor_lower:
return DeviceVendor.CISCO
elif "huawei" in vendor_lower:
return DeviceVendor.HUAWEI
elif "h3c" in vendor_lower:
return DeviceVendor.H3C
elif "juniper" in vendor_lower:
return DeviceVendor.JUNIPER
# 基于主机名判断
hostname_lower = hostname.lower()
if any(keyword in hostname_lower for keyword in ["cisco", "cat", "nexus"]):
return DeviceVendor.CISCO
elif any(keyword in hostname_lower for keyword in ["huawei", "hw"]):
return DeviceVendor.HUAWEI
elif "h3c" in hostname_lower:
return DeviceVendor.H3C
elif any(keyword in hostname_lower for keyword in ["juniper", "mx", "ex", "qfx"]):
return DeviceVendor.JUNIPER
return DeviceVendor.OTHER
async def snmp_query(self, ip: str, community: str = "public", oid: str = "1.3.6.1.2.1.1.1.0", timeout: float = 3.0) -> Optional[str]:
"""SNMP查询(需要安装pysnmp库)"""
try:
# 这里应该使用pysnmp库,但为了避免依赖,先返回None
# 可以后续添加SNMP支持
return None
except Exception as e:
logger.debug(f"SNMP查询 {ip} 失败: {e}")
return None
async def scan_single_host(self, ip: str, config: ScanConfiguration) -> ScanResult:
"""扫描单个主机"""
result = ScanResult(ip_address=ip)
try:
# 1. Ping检测
if config.ping_enabled:
is_alive, response_time = await self.ping_host(ip, config.timeout)
result.is_alive = is_alive
result.response_time = response_time
if is_alive:
result.discovery_method.add(DeviceDiscoveryMethod.PING_SWEEP)
# 如果主机不可达,跳过其他检测
if not result.is_alive:
return result
# 2. 端口扫描
if config.port_scan_enabled:
open_ports = await self.scan_ports(ip, config.common_ports, config.timeout)
result.open_ports = open_ports
if open_ports:
result.discovery_method.add(DeviceDiscoveryMethod.PORT_SCAN)
# 识别服务
for port in open_ports:
service = self.device_port_signatures.get(port, f"Unknown/{port}")
result.services[port] = service
# 3. 获取主机名
hostname = await self.get_hostname(ip)
if hostname and hostname != ip:
result.hostname = hostname
# 4. SNMP检测
if config.snmp_enabled and 161 in result.open_ports:
for community in config.snmp_communities:
snmp_result = await self.snmp_query(ip, community, timeout=config.timeout)
if snmp_result:
result.snmp_info[community] = snmp_result
result.discovery_method.add(DeviceDiscoveryMethod.SNMP_WALK)
break
# 5. 推断设备信息
if result.mac_address:
result.vendor = self.get_vendor_by_mac(result.mac_address)
result.device_type = self.infer_device_type(result).value
vendor_enum = self.infer_device_vendor(result)
if result.vendor is None:
result.vendor = vendor_enum.value
except Exception as e:
logger.error(f"扫描主机 {ip} 时出错: {e}")
return result
async def scan_network_range(self, network: str, config: Optional[ScanConfiguration] = None) -> List[ScanResult]:
"""扫描网络范围"""
if config is None:
config = ScanConfiguration()
logger.info(f"开始扫描网络范围: {network}")
try:
# 解析网络范围
net = ipaddress.ip_network(network, strict=False)
ip_list = [str(ip) for ip in net.hosts()]
# 限制扫描范围(避免扫描过大的网络)
if len(ip_list) > 1000:
logger.warning(f"网络范围过大 ({len(ip_list)} 个主机),限制为前1000个")
ip_list = ip_list[:1000]
logger.info(f"将扫描 {len(ip_list)} 个主机")
# 使用信号量限制并发数
semaphore = asyncio.Semaphore(config.max_concurrent)
async def scan_with_semaphore(ip):
async with semaphore:
return await self.scan_single_host(ip, config)
# 并发扫描
tasks = [scan_with_semaphore(ip) for ip in ip_list]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 过滤成功的结果
valid_results = []
for result in results:
if isinstance(result, ScanResult):
valid_results.append(result)
# 保存到结果字典
self.scan_results[result.ip_address] = result
elif isinstance(result, Exception):
logger.error(f"扫描任务异常: {result}")
# 过滤活跃主机
alive_results = [r for r in valid_results if r.is_alive]
logger.info(f"扫描完成,发现 {len(alive_results)} 个活跃主机")
# 保存扫描结果
self.save_scan_results()
return alive_results
except Exception as e:
logger.error(f"扫描网络范围失败: {e}")
return []
async def discover_devices_from_scan(self, scan_results: List[ScanResult], auto_create: bool = True) -> List[NetworkDevice]:
"""从扫描结果中发现并创建设备"""
discovered_devices = []
for result in scan_results:
if not result.is_alive:
continue
# 检查设备是否已存在
existing_devices = self.device_manager.list_devices()
device_exists = any(device.ip_address == result.ip_address for device in existing_devices)
if device_exists:
logger.debug(f"设备已存在: {result.ip_address}")
continue
if auto_create:
# 创建新设备
device_name = result.hostname or f"Device-{result.ip_address.replace('.', '-')}"
try:
device_type = DeviceType(result.device_type) if result.device_type else DeviceType.OTHER
except ValueError:
device_type = DeviceType.OTHER
try:
vendor = DeviceVendor(result.vendor.lower()) if result.vendor else DeviceVendor.OTHER
except (ValueError, AttributeError):
vendor = DeviceVendor.OTHER
new_device = NetworkDevice(
name=device_name,
ip_address=result.ip_address,
device_type=device_type,
vendor=vendor,
status=DeviceStatus.UNKNOWN,
description=f"通过网络扫描自动发现 (扫描时间: {result.discovered_at.strftime('%Y-%m-%d %H:%M:%S')})",
tags=["网络扫描发现", f"响应时间:{result.response_time}ms" if result.response_time else ""]
)
# 添加到设备管理器
device_id = self.device_manager.add_device(new_device)
discovered_devices.append(new_device)
logger.info(f"创建新设备: {new_device.name} ({new_device.ip_address}) - {new_device.vendor.value}")
return discovered_devices
def get_scan_statistics(self) -> Dict[str, Any]:
"""获取扫描统计信息"""
total_scanned = len(self.scan_results)
alive_hosts = len([r for r in self.scan_results.values() if r.is_alive])
# 厂商统计
vendor_stats = {}
for result in self.scan_results.values():
if result.is_alive and result.vendor:
vendor_stats[result.vendor] = vendor_stats.get(result.vendor, 0) + 1
# 设备类型统计
device_type_stats = {}
for result in self.scan_results.values():
if result.is_alive and result.device_type:
device_type_stats[result.device_type] = device_type_stats.get(result.device_type, 0) + 1
# 发现方法统计
discovery_method_stats = {}
for result in self.scan_results.values():
if result.is_alive:
for method in result.discovery_method:
discovery_method_stats[method.value] = discovery_method_stats.get(method.value, 0) + 1
return {
"total_scanned": total_scanned,
"alive_hosts": alive_hosts,
"discovery_rate": f"{(alive_hosts/total_scanned*100):.1f}%" if total_scanned > 0 else "0%",
"vendor_distribution": vendor_stats,
"device_type_distribution": device_type_stats,
"discovery_method_distribution": discovery_method_stats,
"last_scan_time": max([r.discovered_at for r in self.scan_results.values()]).isoformat() if self.scan_results else None
}
def clear_scan_results(self):
"""清空扫描结果"""
self.scan_results.clear()
self.save_scan_results()
logger.info("扫描结果已清空")
# 创建网络扫描器单例
def create_network_scanner(device_manager: DeviceManager) -> NetworkScanner:
"""创建网络扫描器实例"""
return NetworkScanner(device_manager)