1e1e5957e8
- 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 记录
382 lines
13 KiB
Python
382 lines
13 KiB
Python
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)
|
||
}
|