feat(scan): 增强MAC厂商识别和主机名自动发现
- MAC厂商: 替换硬编码OUI_MAP为mac_vendor_lookup库(IEEE OUI官方数据库) - 主机名发现新增NetBIOS广播(nmblookup),支持Windows/SMB设备 - 综合扫描支持enable_netbios参数 - 新增NetBIOS主机名发现API端点 - 扫描时自动补全缺失厂商(有MAC无vendor时自动识别)
This commit is contained in:
@@ -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(
|
||||
|
||||
Binary file not shown.
@@ -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 <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]:
|
||||
"""
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user