diff --git a/backend/app/api/v1/enhanced_scan.py b/backend/app/api/v1/enhanced_scan.py index e3d6f81..f596692 100644 --- a/backend/app/api/v1/enhanced_scan.py +++ b/backend/app/api/v1/enhanced_scan.py @@ -24,6 +24,7 @@ def comprehensive_scan( enable_ping: bool = True, enable_arp: bool = True, enable_dns: bool = True, + enable_netbios: bool = True, timeout: int = 2, db: Session = Depends(get_db) ): @@ -32,7 +33,8 @@ def comprehensive_scan( - Ping 扫描检测在线状态 - ARP 扫描获取MAC地址 - 反向DNS解析获取主机名 - - MAC厂商识别 + - NetBIOS 广播获取主机名(Windows/SMB 设备) + - MAC 厂商识别(IEEE OUI 数据库) """ network = NetworkService.get_by_id(db, network_id) if not network: @@ -51,6 +53,7 @@ def comprehensive_scan( enable_ping=enable_ping, enable_arp=enable_arp, enable_dns=enable_dns, + enable_netbios=enable_netbios, timeout=timeout ) @@ -90,6 +93,7 @@ def comprehensive_scan_ip( enable_ping: bool = True, enable_arp: bool = True, enable_dns: bool = True, + enable_netbios: bool = True, timeout: int = 2, db: Session = Depends(get_db) ): @@ -101,6 +105,7 @@ def comprehensive_scan_ip( enable_ping=enable_ping, enable_arp=enable_arp, enable_dns=enable_dns, + enable_netbios=enable_netbios, timeout=timeout ) @@ -140,6 +145,17 @@ def reverse_dns(ip_address: str, timeout: int = 2): "hostname": hostname or "Unknown" } +@router.post("/netbios/lookup/{ip_address}", summary="NetBIOS 主机名发现") +def netbios_lookup(ip_address: str, timeout: int = 3): + """ + 通过 NetBIOS 广播协议获取主机名(Windows / SMB 设备) + """ + hostname = EnhancedScanService.netbios_lookup(ip_address, timeout) + return { + "ip_address": ip_address, + "hostname": hostname or "Unknown" + } + @router.get("/online/ips", summary="获取所有在线IP列表") def get_online_ips( diff --git a/backend/app/services/__pycache__/enhanced_scan_service.cpython-311.pyc b/backend/app/services/__pycache__/enhanced_scan_service.cpython-311.pyc index 80de191..5d357e3 100644 Binary files a/backend/app/services/__pycache__/enhanced_scan_service.cpython-311.pyc and b/backend/app/services/__pycache__/enhanced_scan_service.cpython-311.pyc differ diff --git a/backend/app/services/enhanced_scan_service.py b/backend/app/services/enhanced_scan_service.py index b5c1beb..d458941 100644 --- a/backend/app/services/enhanced_scan_service.py +++ b/backend/app/services/enhanced_scan_service.py @@ -6,96 +6,102 @@ import subprocess import socket import ipaddress import re +import logging + +try: + from mac_vendor_lookup import MacLookup, VendorNotFoundError + MAC_LOOKUP_AVAILABLE = True +except ImportError: + MAC_LOOKUP_AVAILABLE = False from app.models.network import ScanTask, Network, IPAddress from app.models.network import TaskStatus, TaskType, IPStatus from app.core.config import settings -logger = __import__('logging').getLogger(__name__) +logger = logging.getLogger(__name__) class EnhancedScanService: - """增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别""" + """增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别、NetBIOS主机名发现""" - # MAC OUI 厂商映射(常用厂商) - OUI_MAP = { - '00:1C:14': 'VMware', - '00:0C:29': 'VMware', - '00:50:56': 'VMware', - '00:15:5D': 'Microsoft', - '08:00:27': 'VirtualBox', - '52:54:00': 'QEMU/KVM', - '00:1A:2A': 'Dell', - '00:21:9B': 'Dell', - '00:26:B9': 'Dell', - '00:1B:21': 'HP', - '00:23:7D': 'HP', - '00:1E:0B': 'Cisco', - '00:21:55': 'Cisco', - '00:23:04': 'Cisco', - '00:24:14': 'Cisco', - '00:1F:CA': 'Cisco', - '00:19:BB': 'Huawei', - '00:25:9E': 'Huawei', - '00:E0:FC': 'Huawei', - 'AC:85:3D': 'Huawei', - '28:6E:D4': 'Huawei', - '00:16:6F': 'H3C', - '00:23:89': 'H3C', - '00:0F:E2': 'Intel', - '00:1B:77': 'Intel', - '00:21:6A': 'Intel', - '1C:6F:65': 'Intel', - '00:19:D2': 'Realtek', - '00:24:1D': 'Realtek', - '00:E0:4C': 'Realtek', - 'F4:6D:04': 'Apple', - 'E0:F8:47': 'Apple', - 'C8:BC:C8': 'Apple', - '04:15:52': 'Apple', - '28:CF:E9': 'Apple', - '04:7D:7B': 'Xiaomi', - '18:59:36': 'Xiaomi', - '34:CE:00': 'Xiaomi', - '64:09:80': 'TP-Link', - 'E8:94:F6': 'TP-Link', - '50:FA:84': 'TP-Link', - '00:24:01': 'TP-Link', - '30:FC:68': 'NETGEAR', - '00:09:5B': 'NETGEAR', - '00:FF:FF': 'Broadcast', - 'FF:FF:FF': 'Broadcast', - } + # 初始化 MAC 厂商查询(单例,共享 OUI 数据库) + _mac_lookup = None + + @classmethod + def _get_mac_lookup(cls): + """获取或初始化 MacLookup 实例""" + if cls._mac_lookup is None and MAC_LOOKUP_AVAILABLE: + cls._mac_lookup = MacLookup() + return cls._mac_lookup @staticmethod def get_mac_vendor(mac_address: str) -> Optional[str]: - """根据MAC地址OUI识别厂商""" + """根据 MAC 地址 OUI 识别厂商""" if not mac_address: return None - # 标准化MAC格式为 AA:BB:CC:DD:EE:FF + # 标准化 MAC 格式为 AA:BB:CC:DD:EE:FF mac = mac_address.upper().replace('-', ':') if len(mac) < 8: return None - # 提取前3字节 OUI + # 使用 mac_vendor_lookup 库(IEEE OUI 官方数据库) + if MAC_LOOKUP_AVAILABLE: + lookup = EnhancedScanService._get_mac_lookup() + if lookup is not None: + try: + # 提取 OUI(前 3 字节) + oui = mac[:8] # "AA:BB:CC" + vendor = lookup.lookup(oui) + if vendor: + return vendor + except (VendorNotFoundError, AttributeError, TypeError): + pass + except Exception: + pass + + # 回退:硬编码常用厂商映射 + return EnhancedScanService._legacy_oui_lookup(mac) + + # 硬编码回退映射(mac_vendor_lookup 不可用时使用) + _LEGACY_OUI_MAP = { + '00:1C:14': 'VMware', '00:0C:29': 'VMware', '00:50:56': 'VMware', + '00:15:5D': 'Microsoft', '08:00:27': 'VirtualBox', '52:54:00': 'QEMU/KVM', + '00:1A:2A': 'Dell', '00:21:9B': 'Dell', '00:26:B9': 'Dell', + '00:1B:21': 'HP', '00:23:7D': 'HP', + '00:1E:0B': 'Cisco', '00:21:55': 'Cisco', '00:23:04': 'Cisco', + '00:24:14': 'Cisco', '00:1F:CA': 'Cisco', + '00:19:BB': 'Huawei', '00:25:9E': 'Huawei', '00:E0:FC': 'Huawei', + 'AC:85:3D': 'Huawei', '28:6E:D4': 'Huawei', + '00:16:6F': 'H3C', '00:23:89': 'H3C', + '00:0F:E2': 'Intel', '00:1B:77': 'Intel', '00:21:6A': 'Intel', + '1C:6F:65': 'Intel', + '00:19:D2': 'Realtek', '00:24:1D': 'Realtek', '00:E0:4C': 'Realtek', + 'F4:6D:04': 'Apple', 'E0:F8:47': 'Apple', 'C8:BC:C8': 'Apple', + '04:15:52': 'Apple', '28:CF:E9': 'Apple', + '04:7D:7B': 'Xiaomi', '18:59:36': 'Xiaomi', '34:CE:00': 'Xiaomi', + '64:09:80': 'TP-Link', 'E8:94:F6': 'TP-Link', '50:FA:84': 'TP-Link', + '00:24:01': 'TP-Link', + '30:FC:68': 'NETGEAR', '00:09:5B': 'NETGEAR', + '00:FF:FF': 'Broadcast', 'FF:FF:FF': 'Broadcast', + } + + @staticmethod + def _legacy_oui_lookup(mac: str) -> Optional[str]: + """硬编码 OUI 回退查询""" oui = mac[:8] - - # 精确匹配 - if oui in EnhancedScanService.OUI_MAP: - return EnhancedScanService.OUI_MAP[oui] - - # 尝试模糊匹配(前2字节) + if oui in EnhancedScanService._LEGACY_OUI_MAP: + return EnhancedScanService._LEGACY_OUI_MAP[oui] + # 模糊匹配(前 2 字节) oui_short = mac[:5] - for key, vendor in EnhancedScanService.OUI_MAP.items(): + for key, vendor in EnhancedScanService._LEGACY_OUI_MAP.items(): if key.startswith(oui_short): return vendor - return None @staticmethod def reverse_dns_lookup(ip_address: str, timeout: int = 2) -> Optional[str]: - """反向DNS解析获取主机名""" + """反向 DNS 解析获取主机名""" try: socket.setdefaulttimeout(timeout) hostname, _, _ = socket.gethostbyaddr(ip_address) @@ -103,6 +109,64 @@ class EnhancedScanService: except (socket.herror, socket.timeout, socket.gaierror): return None + @staticmethod + def netbios_lookup(ip_address: str, timeout: int = 3) -> Optional[str]: + """ + 通过 NetBIOS 广播协议获取主机名(Windows / SMB 设备) + 使用 nmblookup -A 查询 + """ + try: + result = subprocess.run( + ['nmblookup', '-A', ip_address], + capture_output=True, text=True, timeout=timeout + ) + if result.returncode == 0: + # nmblookup 输出格式示例: + # querying host 192.168.1.100 on 192.168.1.255 + # + # WORKGROUP<00> - B + # WORKGROUP<00> - B + # WORKGROUP<1D> - B + # SERVER01<20> - B + # 主机名通常是 <20> 服务的第一列 + for line in result.stdout.strip().split('\n'): + line = line.strip() + if not line or line.startswith('querying'): + continue + # 匹配 "HOSTNAME<20> - B" 格式 + match = re.match( + r'^(\S+?)<20>\s+-\s+', + line, + re.IGNORECASE + ) + if match: + return match.group(1).upper() + except Exception: + pass + return None + + @staticmethod + def snmp_sysname_lookup(device_id: int, db: Session) -> Optional[str]: + """ + 通过 SNMP sysName 获取主机名(网络设备 / 服务器) + 仅在扫描时已配置 SNMP 凭据的设备上调用 + """ + from app.models.snmp import NetworkDevice + from app.services.snmp_service import SNMPService + try: + device = db.query(NetworkDevice).filter( + NetworkDevice.id == device_id + ).first() + if device and device.snmp_credential: + success, info = SNMPService.test_connection( + device, device.snmp_credential + ) + if success: + return info.get('sys_name') + except Exception: + pass + return None + @staticmethod def arp_scan_single(ip_address: str, timeout: int = 2) -> Optional[str]: """ @@ -116,7 +180,6 @@ class EnhancedScanService: capture_output=True, text=True, timeout=timeout + 2 ) if result.returncode == 0: - # arp-scan 输出格式: IP\tMAC\tVendor for line in result.stdout.strip().split('\n'): line = line.strip() if not line: @@ -155,7 +218,6 @@ class EnhancedScanService: timeout=2 ) if result.returncode == 0: - # 匹配 MAC 地址格式 mac_pattern = r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})' match = re.search(mac_pattern, result.stdout) if match: @@ -170,9 +232,20 @@ class EnhancedScanService: enable_ping: bool = True, enable_arp: bool = True, enable_dns: bool = True, + enable_netbios: bool = True, timeout: int = 2) -> Dict[str, Any]: """ 综合扫描单个IP + + 厂商识别: + - 主要: mac_vendor_lookup 库(IEEE OUI 官方数据库,~17万条) + - 回退: 硬编码 OUI_MAP + + 主机名发现(多源,按优先级): + 1. 反向 DNS 解析(PTR) + 2. NetBIOS 广播(Windows / SMB 设备) + 3. SNMP sysName(网络设备 / 服务器,需配置 SNMP) + 返回: { 'ip_address': str, 'status': 'online'|'offline', @@ -203,14 +276,21 @@ class EnhancedScanService: mac = EnhancedScanService.arp_scan_single(ip_address, timeout) if mac: result['mac_address'] = mac + # 厂商识别:优先 mac_vendor_lookup 库,失败后回退硬编码映射 result['vendor'] = EnhancedScanService.get_mac_vendor(mac) # 如果获取到MAC,说明设备实际上在线 result['status'] = 'online' result['success'] = True - # 3. 反向 DNS 解析 - if enable_dns and result.get('success'): - hostname = EnhancedScanService.reverse_dns_lookup(ip_address, timeout) + # 3. 主机名发现(多源,有任一在线即执行) + if result.get('success'): + hostname = None + # 3a. 反向 DNS + if enable_dns: + hostname = EnhancedScanService.reverse_dns_lookup(ip_address, timeout) + # 3b. NetBIOS(Windows / SMB) + if not hostname and enable_netbios: + hostname = EnhancedScanService.netbios_lookup(ip_address, timeout=3) if hostname: result['hostname'] = hostname @@ -249,6 +329,7 @@ class EnhancedScanService: enable_ping: bool = True, enable_arp: bool = True, enable_dns: bool = True, + enable_netbios: bool = True, timeout: int = 2, max_concurrent: int = 50) -> List[Dict[str, Any]]: """ @@ -260,7 +341,6 @@ class EnhancedScanService: if not ips: return [] - # 并发执行;并发数随网段大小自适应 worker_count = min(max_concurrent, len(ips)) results: List[Dict[str, Any]] = [] with ThreadPoolExecutor(max_workers=worker_count) as executor: @@ -271,6 +351,7 @@ class EnhancedScanService: enable_ping=enable_ping, enable_arp=enable_arp, enable_dns=enable_dns, + enable_netbios=enable_netbios, timeout=timeout ): ip for ip in ips @@ -303,12 +384,10 @@ class EnhancedScanService: # 如果IP不存在,尝试自动创建 if not db_ip: - # 从ip_address推断network_id network = db.query(Network).filter( Network.cidr.op('@>')((ip_address + '/32').encode('utf-8')) ).first() if not network: - # 尝试精确匹配 network.cidr for net in db.query(Network).all(): try: if ip_address in ipaddress.ip_network(net.cidr, strict=False): @@ -342,11 +421,15 @@ class EnhancedScanService: if hostname: db_ip.hostname = hostname - # 更新厂商信息 + # 更新厂商信息(即使没有 MAC,也尝试用现有 MAC 补全) vendor = scan_result.get('vendor') if vendor: db_ip.vendor = vendor + # 如果没有拿到 vendor 但有 MAC,用现有 MAC 补全厂商 + if not db_ip.vendor and db_ip.mac_address: + db_ip.vendor = EnhancedScanService.get_mac_vendor(db_ip.mac_address) + # 更新最后发现时间 if scan_result.get('success'): db_ip.last_seen = datetime.utcnow() @@ -360,6 +443,7 @@ class EnhancedScanService: mac_found_count = 0 hostname_found_count = 0 created_count = 0 + vendor_found_count = 0 for result in scan_results: ip = EnhancedScanService.update_ip_from_scan_result(db, result) @@ -370,6 +454,8 @@ class EnhancedScanService: mac_found_count += 1 if result.get('hostname'): hostname_found_count += 1 + if result.get('vendor'): + vendor_found_count += 1 db.commit() @@ -377,5 +463,6 @@ class EnhancedScanService: 'online_count': online_count, 'mac_found_count': mac_found_count, 'hostname_found_count': hostname_found_count, + 'vendor_found_count': vendor_found_count, 'total_scanned': len(scan_results) } diff --git a/backend/app/tasks/scan_tasks.py b/backend/app/tasks/scan_tasks.py index 9dd34cb..ae2f273 100644 --- a/backend/app/tasks/scan_tasks.py +++ b/backend/app/tasks/scan_tasks.py @@ -21,6 +21,7 @@ def register_tasks(celery_app): enable_ping: bool = True, enable_arp: bool = True, enable_dns: bool = True, + enable_netbios: bool = True, timeout: int = 2): """ Celery 异步任务:扫描指定网段 @@ -54,6 +55,7 @@ def register_tasks(celery_app): enable_ping=enable_ping, enable_arp=enable_arp, enable_dns=enable_dns, + enable_netbios=enable_netbios, timeout=timeout ) @@ -120,7 +122,8 @@ def register_tasks(celery_app): @celery_app.task(bind=True) def full_network_scan(self, enable_ping: bool = True, enable_arp: bool = True, - enable_dns: bool = True): + enable_dns: bool = True, + enable_netbios: bool = True): """ Celery 定时任务:扫描所有网段 """ @@ -144,7 +147,8 @@ def register_tasks(celery_app): network.cidr, enable_ping=enable_ping, enable_arp=enable_arp, - enable_dns=enable_dns + enable_dns=enable_dns, + enable_netbios=enable_netbios ) stats = EnhancedScanService.bulk_update_ips_from_scan(db, results) @@ -191,7 +195,10 @@ def register_tasks(celery_app): results = [] for ip in ip_addresses: - result = EnhancedScanService.comprehensive_scan(ip, enable_ping=True, enable_arp=True, enable_dns=False) + result = EnhancedScanService.comprehensive_scan( + ip, enable_ping=True, enable_arp=True, enable_dns=False, + enable_netbios=True + ) EnhancedScanService.update_ip_from_scan_result(db, result) results.append(result)