Files
ipam/backend/app/services/enhanced_scan_service.py
T
Your Name ccd7fbe2f0 feat(scan): 增强MAC厂商识别和主机名自动发现
- MAC厂商: 替换硬编码OUI_MAP为mac_vendor_lookup库(IEEE OUI官方数据库)
- 主机名发现新增NetBIOS广播(nmblookup),支持Windows/SMB设备
- 综合扫描支持enable_netbios参数
- 新增NetBIOS主机名发现API端点
- 扫描时自动补全缺失厂商(有MAC无vendor时自动识别)
2026-07-18 10:53:33 +08:00

469 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
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 = logging.getLogger(__name__)
class EnhancedScanService:
"""增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别、NetBIOS主机名发现"""
# 初始化 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 识别厂商"""
if not mac_address:
return None
# 标准化 MAC 格式为 AA:BB:CC:DD:EE:FF
mac = mac_address.upper().replace('-', ':')
if len(mac) < 8:
return None
# 使用 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._LEGACY_OUI_MAP:
return EnhancedScanService._LEGACY_OUI_MAP[oui]
# 模糊匹配(前 2 字节)
oui_short = mac[:5]
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 解析获取主机名"""
try:
socket.setdefaulttimeout(timeout)
hostname, _, _ = socket.gethostbyaddr(ip_address)
return hostname
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 <ip> 查询
"""
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> - <ACTIVE> B
# WORKGROUP<00> - <GROUP> B
# WORKGROUP<1D> - <GROUP> B
# SERVER01<20> - <ACTIVE> B
# 主机名通常是 <20> 服务的第一列
for line in result.stdout.strip().split('\n'):
line = line.strip()
if not line or line.startswith('querying'):
continue
# 匹配 "HOSTNAME<20> - <ACTIVE> B" 格式
match = re.match(
r'^(\S+?)<20>\s+-\s+<ACTIVE>',
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]:
"""
对单个IP执行ARP扫描获取MAC地址
返回: MAC地址 或 None
"""
# 方法1: 使用 arp-scan 命令(比 scapy 更可靠,不依赖 raw socket
try:
result = subprocess.run(
['arp-scan', '-I', 'auto', ip_address, '-q'],
capture_output=True, text=True, timeout=timeout + 2
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
line = line.strip()
if not line:
continue
parts = line.split('\t')
if len(parts) >= 2:
mac = parts[1].strip()
if mac and mac != '<incomplete>':
return mac.upper()
except Exception:
pass
# 方法2: 尝试读取系统 ARP 缓存
return EnhancedScanService._get_arp_from_cache(ip_address)
@staticmethod
def _get_arp_from_cache(ip_address: str) -> Optional[str]:
"""从系统ARP缓存读取MAC地址"""
try:
# 读取 /proc/net/arp (Linux)
with open('/proc/net/arp', 'r') as f:
for line in f.readlines()[1:]: # 跳过表头
parts = line.split()
if len(parts) >= 4 and parts[0] == ip_address:
mac = parts[3].upper()
if mac != '00:00:00:00:00:00':
return mac
except Exception:
pass
# 尝试 arp -a 命令
try:
result = subprocess.run(
['arp', '-a', ip_address],
capture_output=True, text=True,
timeout=2
)
if result.returncode == 0:
mac_pattern = r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})'
match = re.search(mac_pattern, result.stdout)
if match:
return match.group().upper()
except Exception:
pass
return None
@staticmethod
def comprehensive_scan(ip_address: str,
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',
'mac_address': str|None,
'hostname': str|None,
'vendor': str|None,
'response_time': float|None
}
"""
result = {
'ip_address': ip_address,
'status': 'offline',
'mac_address': None,
'hostname': None,
'vendor': None,
'success': False
}
# 1. Ping 扫描
if enable_ping:
ping_result = EnhancedScanService.ping_single_ip(ip_address, timeout)
result['status'] = ping_result.get('status', 'offline')
result['success'] = ping_result.get('success', False)
result['response_time'] = ping_result.get('response_time')
# 2. ARP 扫描 (即使ping不通也尝试,有些设备禁ping)
if enable_arp:
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. 主机名发现(多源,有任一在线即执行)
if result.get('success'):
hostname = None
# 3a. 反向 DNS
if enable_dns:
hostname = EnhancedScanService.reverse_dns_lookup(ip_address, timeout)
# 3b. NetBIOSWindows / SMB
if not hostname and enable_netbios:
hostname = EnhancedScanService.netbios_lookup(ip_address, timeout=3)
if hostname:
result['hostname'] = hostname
return result
@staticmethod
def ping_single_ip(ip_address: str, timeout: int = 2) -> Dict[str, Any]:
"""Ping 单个IP"""
start_time = datetime.now()
try:
result = subprocess.run(
['ping', '-c', '1', '-W', str(timeout), ip_address],
capture_output=True,
text=True
)
is_alive = result.returncode == 0
response_time = (datetime.now() - start_time).total_seconds() * 1000
return {
'ip_address': ip_address,
'status': 'online' if is_alive else 'offline',
'response_time': round(response_time, 2),
'success': is_alive
}
except Exception as e:
return {
'ip_address': ip_address,
'status': 'error',
'error': str(e),
'success': False
}
@staticmethod
def scan_network(cidr: str,
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]]:
"""
扫描整个网段(并发)
"""
network = ipaddress.ip_network(cidr, strict=False)
ips = [str(ip) for ip in network.hosts()]
if not ips:
return []
worker_count = min(max_concurrent, len(ips))
results: List[Dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=worker_count) as executor:
future_to_ip = {
executor.submit(
EnhancedScanService.comprehensive_scan,
ip,
enable_ping=enable_ping,
enable_arp=enable_arp,
enable_dns=enable_dns,
enable_netbios=enable_netbios,
timeout=timeout
): ip
for ip in ips
}
for future in as_completed(future_to_ip):
try:
results.append(future.result())
except Exception as e:
ip = future_to_ip[future]
results.append({
'ip_address': ip,
'status': 'offline',
'mac_address': None,
'hostname': None,
'vendor': None,
'success': False,
'error': str(e)
})
return results
@staticmethod
def update_ip_from_scan_result(db: Session, scan_result: Dict[str, Any]):
"""根据扫描结果更新IP信息"""
ip_address = scan_result.get('ip_address')
if not ip_address:
return
db_ip = db.query(IPAddress).filter(IPAddress.ip_address == ip_address).first()
# 如果IP不存在,尝试自动创建
if not db_ip:
network = db.query(Network).filter(
Network.cidr.op('@>')((ip_address + '/32').encode('utf-8'))
).first()
if not network:
for net in db.query(Network).all():
try:
if ip_address in ipaddress.ip_network(net.cidr, strict=False):
network = net
break
except Exception:
pass
if not network:
logger.warning(f"无法为扫描到的IP {ip_address} 找到所属网段,跳过创建")
return
db_ip = IPAddress(ip_address=ip_address, network_id=network.id)
db.add(db_ip)
db.commit()
db.refresh(db_ip)
# 更新状态
status = scan_result.get('status', 'offline')
if status == 'online':
db_ip.status = IPStatus.ONLINE
elif status == 'offline' and db_ip.status == IPStatus.ONLINE:
db_ip.status = IPStatus.OFFLINE
# 更新 MAC 地址
mac = scan_result.get('mac_address')
if mac:
db_ip.mac_address = mac
# 更新主机名
hostname = scan_result.get('hostname')
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()
return db_ip
@staticmethod
def bulk_update_ips_from_scan(db: Session, scan_results: List[Dict[str, Any]]):
"""批量更新IP信息"""
online_count = 0
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)
if ip:
if result.get('success'):
online_count += 1
if result.get('mac_address'):
mac_found_count += 1
if result.get('hostname'):
hostname_found_count += 1
if result.get('vendor'):
vendor_found_count += 1
db.commit()
return {
'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)
}