Files
ipam/backend/app/services/enhanced_scan_service.py
T
Your Name 1e1e5957e8 fix(ipam): 修复IP管理页面网段筛选不生效及下拉框过窄
- Ips.vue: el-select 添加 width: 280px,修复网段下拉框过窄
- Ips.vue: query 参数 networkId → network_id(与 FastAPI snake_case 一致)
- Scan.vue: el-select 用 network.id 代替整个 network 对象作为 value,修复切换网段不生效
- enhanced_scan_service.py: 替换失效的 scapy ARP 扫描为 arp-scan 命令;扫描结果自动创建缺失 IP 记录
2026-07-18 09:45:30 +08:00

382 lines
13 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
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__)
class EnhancedScanService:
"""增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别"""
# 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',
}
@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
# 提取前3字节 OUI
oui = mac[:8]
# 精确匹配
if oui in EnhancedScanService.OUI_MAP:
return EnhancedScanService.OUI_MAP[oui]
# 尝试模糊匹配(前2字节)
oui_short = mac[:5]
for key, vendor in EnhancedScanService.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 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:
# arp-scan 输出格式: IP\tMAC\tVendor
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 地址格式
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,
timeout: int = 2) -> Dict[str, Any]:
"""
综合扫描单个IP
返回: {
'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
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)
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,
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,
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:
# 从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):
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
# 更新厂商信息
vendor = scan_result.get('vendor')
if vendor:
db_ip.vendor = vendor
# 更新最后发现时间
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
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
db.commit()
return {
'online_count': online_count,
'mac_found_count': mac_found_count,
'hostname_found_count': hostname_found_count,
'total_scanned': len(scan_results)
}