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 记录
521 lines
20 KiB
Python
521 lines
20 KiB
Python
from typing import List, Dict, Any, Optional, Tuple
|
||
from sqlalchemy.orm import Session
|
||
from datetime import datetime
|
||
import ipaddress
|
||
import logging
|
||
|
||
from pysnmp.hlapi import (
|
||
SnmpEngine, CommunityData, UsmUserData,
|
||
UdpTransportTarget, ContextData,
|
||
ObjectType, ObjectIdentity,
|
||
bulkCmd, getCmd, nextCmd
|
||
)
|
||
from pysnmp.proto.rfc1902 import OctetString, Integer32, Counter32, Counter64, Gauge32
|
||
|
||
from app.models.snmp import (
|
||
SNMPCredential, NetworkDevice, ARPEntry, MACAddressTable, DeviceInterface,
|
||
SNMPVersion, SNMPAuthProtocol, SNMPPrivProtocol
|
||
)
|
||
from app.services.enhanced_scan_service import EnhancedScanService
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class SNMPService:
|
||
"""SNMP 服务"""
|
||
|
||
# OID 定义
|
||
OID_SYSTEM = '1.3.6.1.2.1.1'
|
||
OID_SYS_DESCR = '1.3.6.1.2.1.1.1.0'
|
||
OID_SYS_NAME = '1.3.6.1.2.1.1.5.0'
|
||
OID_SYS_LOCATION = '1.3.6.1.2.1.1.6.0'
|
||
|
||
# 接口相关 OID
|
||
OID_IF_TABLE = '1.3.6.1.2.1.2.2'
|
||
OID_IF_INDEX = '1.3.6.1.2.1.2.2.1.1'
|
||
OID_IF_DESCR = '1.3.6.1.2.1.2.2.1.2'
|
||
OID_IF_TYPE = '1.3.6.1.2.1.2.2.1.3'
|
||
OID_IF_MTU = '1.3.6.1.2.1.2.2.1.4'
|
||
OID_IF_SPEED = '1.3.6.1.2.1.2.2.1.5'
|
||
OID_IF_PHYS_ADDRESS = '1.3.6.1.2.1.2.2.1.6'
|
||
OID_IF_ADMIN_STATUS = '1.3.6.1.2.1.2.2.1.7'
|
||
OID_IF_OPER_STATUS = '1.3.6.1.2.1.2.2.1.8'
|
||
OID_IF_IN_OCTETS = '1.3.6.1.2.1.2.2.1.10'
|
||
OID_IF_OUT_OCTETS = '1.3.6.1.2.1.2.2.1.16'
|
||
OID_IF_IN_ERRORS = '1.3.6.1.2.1.2.2.1.14'
|
||
OID_IF_OUT_ERRORS = '1.3.6.1.2.1.2.2.1.20'
|
||
|
||
# IP 地址表
|
||
OID_IP_ADDR_TABLE = '1.3.6.1.2.1.4.20'
|
||
OID_IP_ADDR_ENTRIES = '1.3.6.1.2.1.4.20.1.1'
|
||
OID_IP_ADDR_IF_INDEX = '1.3.6.1.2.1.4.20.1.2'
|
||
OID_IP_ADDR_NETMASK = '1.3.6.1.2.1.4.20.1.3'
|
||
|
||
# ARP 表 (ipNetToMediaTable)
|
||
OID_IP_NET_TO_MEDIA_PHYS_ADDRESS = '1.3.6.1.2.1.4.22.1.2'
|
||
OID_IP_NET_TO_MEDIA_IF_INDEX = '1.3.6.1.2.1.4.22.1.1'
|
||
OID_IP_NET_TO_MEDIA_NET_ADDRESS = '1.3.6.1.2.1.4.22.1.3'
|
||
OID_IP_NET_TO_MEDIA_TYPE = '1.3.6.1.2.1.4.22.1.4'
|
||
|
||
# DOT1D 桥接 MIB (MAC 地址表)
|
||
OID_DOT1D_TP_FDB_PORT = '1.3.6.1.2.1.17.4.3.1.2'
|
||
OID_DOT1D_TP_FDB_STATUS = '1.3.6.1.2.1.17.4.3.1.3'
|
||
|
||
# VLAN 相关 (Cisco 特定)
|
||
OID_VTP_VLAN_STATE = '1.3.6.1.4.1.9.9.46.1.3.1.1.2'
|
||
|
||
@staticmethod
|
||
def _create_auth_data(credential: SNMPCredential):
|
||
"""创建 SNMP 认证数据"""
|
||
if credential.version == SNMPVersion.V2C:
|
||
if not credential.community_string:
|
||
raise ValueError("SNMPv2c 凭据缺少 community_string")
|
||
return CommunityData(credential.community_string)
|
||
else: # V3
|
||
if not credential.username:
|
||
raise ValueError("SNMPv3 凭据缺少 username(V3 强制要求 1-32 字符)")
|
||
|
||
auth_protocol_map = {
|
||
SNMPAuthProtocol.MD5: 'MD5',
|
||
SNMPAuthProtocol.SHA: 'SHA',
|
||
SNMPAuthProtocol.SHA256: 'SHA256',
|
||
}
|
||
priv_protocol_map = {
|
||
SNMPPrivProtocol.DES: 'DES',
|
||
SNMPPrivProtocol.AES: 'AES',
|
||
SNMPPrivProtocol.AES256: 'AES256',
|
||
}
|
||
|
||
auth_proto = auth_protocol_map.get(credential.auth_protocol) if credential.auth_protocol else None
|
||
priv_proto = priv_protocol_map.get(credential.priv_protocol) if credential.priv_protocol else None
|
||
|
||
return UsmUserData(
|
||
userName=credential.username,
|
||
authProtocol=auth_proto,
|
||
authKey=credential.auth_password,
|
||
privProtocol=priv_proto,
|
||
privKey=credential.priv_password
|
||
)
|
||
|
||
@staticmethod
|
||
def _get_transport_target(ip_address: str, port: int = 161, timeout: int = 3, retries: int = 2):
|
||
"""创建传输目标"""
|
||
return UdpTransportTarget((ip_address, port), timeout=timeout, retries=retries)
|
||
|
||
@staticmethod
|
||
def _normalize_mac(mac_bytes) -> Optional[str]:
|
||
"""将 MAC 地址字节转换为标准格式 AA:BB:CC:DD:EE:FF"""
|
||
if not mac_bytes:
|
||
return None
|
||
|
||
if isinstance(mac_bytes, OctetString):
|
||
mac_bytes = mac_bytes.asOctets()
|
||
|
||
if len(mac_bytes) == 6:
|
||
return ':'.join(f'{b:02X}' for b in mac_bytes)
|
||
|
||
return None
|
||
|
||
@staticmethod
|
||
def test_connection(device: NetworkDevice, credential: SNMPCredential) -> Tuple[bool, Dict[str, Any]]:
|
||
"""
|
||
测试 SNMP 连接
|
||
返回: (成功, 设备信息字典)
|
||
"""
|
||
try:
|
||
auth_data = SNMPService._create_auth_data(credential)
|
||
transport = SNMPService._get_transport_target(
|
||
device.ip_address,
|
||
port=device.port,
|
||
timeout=credential.timeout,
|
||
retries=credential.retries
|
||
)
|
||
|
||
errorIndication, errorStatus, errorIndex, varBinds = next(
|
||
getCmd(SnmpEngine(), auth_data, transport, ContextData(),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_DESCR)),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_NAME)),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_LOCATION)))
|
||
)
|
||
|
||
if errorIndication:
|
||
return False, {'error': str(errorIndication)}
|
||
if errorStatus:
|
||
return False, {'error': f'{errorStatus.prettyPrint()} at {errorIndex and varBinds[int(errorIndex) - 1][0] or "?"}'}
|
||
|
||
sys_info = {}
|
||
for varBind in varBinds:
|
||
oid = str(varBind[0])
|
||
value = varBind[1].prettyPrint()
|
||
|
||
if SNMPService.OID_SYS_DESCR in oid:
|
||
sys_info['sys_descr'] = value
|
||
elif SNMPService.OID_SYS_NAME in oid:
|
||
sys_info['sys_name'] = value
|
||
elif SNMPService.OID_SYS_LOCATION in oid:
|
||
sys_info['sys_location'] = value
|
||
|
||
return True, sys_info
|
||
|
||
except Exception as e:
|
||
logger.error(f"SNMP 连接测试失败: {e}")
|
||
return False, {'error': str(e)}
|
||
|
||
@staticmethod
|
||
def get_arp_table(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||
"""
|
||
获取 ARP 表 (IP -> MAC 映射)
|
||
"""
|
||
arp_entries = []
|
||
|
||
try:
|
||
auth_data = SNMPService._create_auth_data(credential)
|
||
transport = SNMPService._get_transport_target(
|
||
device.ip_address,
|
||
port=device.port,
|
||
timeout=credential.timeout,
|
||
retries=credential.retries
|
||
)
|
||
|
||
# 使用 bulkCmd 获取 ARP 表
|
||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||
SnmpEngine(), auth_data, transport, ContextData(),
|
||
0, 50, # nonRepeaters, maxRepetitions
|
||
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_PHYS_ADDRESS)),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_NET_ADDRESS)),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_IF_INDEX)),
|
||
lexicographicMode=False
|
||
):
|
||
if errorIndication:
|
||
logger.error(f"SNMP 错误: {errorIndication}")
|
||
break
|
||
|
||
if errorStatus:
|
||
logger.error(f"SNMP 错误: {errorStatus}")
|
||
break
|
||
|
||
# 解析结果
|
||
arp_data = {}
|
||
for varBind in varBinds:
|
||
oid = str(varBind[0])
|
||
value = varBind[1]
|
||
|
||
# 提取 IP 地址 (OID 后缀: ifIndex.IP)
|
||
parts = oid.split('.')
|
||
if len(parts) >= 4:
|
||
# 从 OID 中提取 IP 地址
|
||
ip_parts = parts[-4:]
|
||
ip_address = '.'.join(ip_parts)
|
||
|
||
if SNMPService.OID_IP_NET_TO_MEDIA_PHYS_ADDRESS in oid:
|
||
mac = SNMPService._normalize_mac(value)
|
||
if mac:
|
||
if ip_address not in arp_data:
|
||
arp_data[ip_address] = {}
|
||
arp_data[ip_address]['mac_address'] = mac
|
||
elif SNMPService.OID_IP_NET_TO_MEDIA_IF_INDEX in oid:
|
||
if isinstance(value, Integer32):
|
||
if_index = int(value)
|
||
if ip_address not in arp_data:
|
||
arp_data[ip_address] = {}
|
||
arp_data[ip_address]['if_index'] = if_index
|
||
|
||
# 转换为列表
|
||
for ip, data in arp_data.items():
|
||
if 'mac_address' in data:
|
||
entry = {
|
||
'ip_address': ip,
|
||
'mac_address': data.get('mac_address'),
|
||
'interface': str(data.get('if_index', '')),
|
||
'device_id': device.id
|
||
}
|
||
arp_entries.append(entry)
|
||
|
||
# 保存到数据库
|
||
now = datetime.utcnow()
|
||
for entry_data in arp_entries:
|
||
# 查找是否已存在
|
||
existing = db.query(ARPEntry).filter(
|
||
ARPEntry.device_id == device.id,
|
||
ARPEntry.ip_address == entry_data['ip_address']
|
||
).first()
|
||
|
||
if existing:
|
||
existing.mac_address = entry_data['mac_address']
|
||
existing.last_seen = now
|
||
else:
|
||
arp_entry = ARPEntry(
|
||
device_id=device.id,
|
||
ip_address=entry_data['ip_address'],
|
||
mac_address=entry_data['mac_address'],
|
||
interface=entry_data['interface'],
|
||
discovered_at=now,
|
||
last_seen=now
|
||
)
|
||
db.add(arp_entry)
|
||
|
||
db.commit()
|
||
|
||
# 更新 IP 资产台账中的 MAC 地址
|
||
for entry in arp_entries:
|
||
from app.models.network import IPAddress as IPAddressModel
|
||
ip_addr = db.query(IPAddressModel).filter(
|
||
IPAddressModel.ip_address == entry['ip_address']
|
||
).first()
|
||
if ip_addr and not ip_addr.mac_address:
|
||
ip_addr.mac_address = entry['mac_address']
|
||
# 识别厂商
|
||
ip_addr.vendor = EnhancedScanService.get_mac_vendor(entry['mac_address'])
|
||
|
||
db.commit()
|
||
device.last_successful_poll = now
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取 ARP 表失败: {e}", exc_info=True)
|
||
|
||
device.last_polled_at = datetime.utcnow()
|
||
db.commit()
|
||
|
||
return arp_entries
|
||
|
||
@staticmethod
|
||
def get_mac_address_table(device: NetworkDevice, credential: SNMPCredential, db: Session, vlan_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||
"""
|
||
获取 MAC 地址表 (MAC -> 端口 映射)
|
||
"""
|
||
mac_entries = []
|
||
|
||
try:
|
||
auth_data = SNMPService._create_auth_data(credential)
|
||
transport = SNMPService._get_transport_target(
|
||
device.ip_address,
|
||
port=device.port,
|
||
timeout=credential.timeout,
|
||
retries=credential.retries
|
||
)
|
||
|
||
# 获取 MAC 地址表
|
||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||
SnmpEngine(), auth_data, transport, ContextData(),
|
||
0, 100,
|
||
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_PORT)),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_STATUS)),
|
||
lexicographicMode=False
|
||
):
|
||
if errorIndication:
|
||
break
|
||
|
||
if errorStatus:
|
||
break
|
||
|
||
for varBind in varBinds:
|
||
oid = str(varBind[0])
|
||
value = varBind[1]
|
||
|
||
# OID 后缀是 MAC 地址: .a.b.c.d.e.f
|
||
parts = oid.split('.')
|
||
if len(parts) >= 6:
|
||
mac_parts = parts[-6:]
|
||
mac_address = ':'.join(f'{int(p):02X}' for p in mac_parts)
|
||
|
||
if SNMPService.OID_DOT1D_TP_FDB_PORT in oid:
|
||
port = int(value) if isinstance(value, Integer32) else None
|
||
|
||
entry = {
|
||
'mac_address': mac_address,
|
||
'port_number': port,
|
||
'device_id': device.id,
|
||
'vlan_id': vlan_id
|
||
}
|
||
mac_entries.append(entry)
|
||
|
||
# 保存到数据库
|
||
now = datetime.utcnow()
|
||
for entry_data in mac_entries:
|
||
existing = db.query(MACAddressTable).filter(
|
||
MACAddressTable.device_id == device.id,
|
||
MACAddressTable.mac_address == entry_data['mac_address']
|
||
).first()
|
||
|
||
if existing:
|
||
existing.port_number = entry_data['port_number']
|
||
existing.last_seen = now
|
||
else:
|
||
mac_entry = MACAddressTable(
|
||
device_id=device.id,
|
||
mac_address=entry_data['mac_address'],
|
||
port_number=entry_data['port_number'],
|
||
vlan_id=vlan_id,
|
||
discovered_at=now,
|
||
last_seen=now
|
||
)
|
||
db.add(mac_entry)
|
||
|
||
db.commit()
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取 MAC 地址表失败: {e}", exc_info=True)
|
||
|
||
return mac_entries
|
||
|
||
@staticmethod
|
||
def get_interfaces(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||
"""
|
||
获取设备接口信息
|
||
"""
|
||
interfaces = []
|
||
|
||
try:
|
||
auth_data = SNMPService._create_auth_data(credential)
|
||
transport = SNMPService._get_transport_target(
|
||
device.ip_address,
|
||
port=device.port,
|
||
timeout=credential.timeout,
|
||
retries=credential.retries
|
||
)
|
||
|
||
# 获取接口信息
|
||
interface_data = {}
|
||
|
||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||
SnmpEngine(), auth_data, transport, ContextData(),
|
||
0, 100,
|
||
ObjectType(ObjectIdentity(SNMPService.OID_IF_DESCR)),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_IF_TYPE)),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_IF_MTU)),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_IF_SPEED)),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_IF_PHYS_ADDRESS)),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_IF_ADMIN_STATUS)),
|
||
ObjectType(ObjectIdentity(SNMPService.OID_IF_OPER_STATUS)),
|
||
lexicographicMode=False
|
||
):
|
||
if errorIndication or errorStatus:
|
||
break
|
||
|
||
for varBind in varBinds:
|
||
oid = str(varBind[0])
|
||
value = varBind[1]
|
||
parts = oid.split('.')
|
||
if_index = parts[-1]
|
||
|
||
if if_index not in interface_data:
|
||
interface_data[if_index] = {'if_index': int(if_index)}
|
||
|
||
if SNMPService.OID_IF_DESCR in oid:
|
||
interface_data[if_index]['if_descr'] = value.prettyPrint()
|
||
elif SNMPService.OID_IF_TYPE in oid:
|
||
interface_data[if_index]['if_type'] = str(value)
|
||
elif SNMPService.OID_IF_MTU in oid:
|
||
interface_data[if_index]['if_mtu'] = int(value) if isinstance(value, Integer32) else None
|
||
elif SNMPService.OID_IF_SPEED in oid:
|
||
interface_data[if_index]['if_speed'] = int(value) if isinstance(value, (Integer32, Gauge32)) else None
|
||
elif SNMPService.OID_IF_PHYS_ADDRESS in oid:
|
||
interface_data[if_index]['if_phys_address'] = SNMPService._normalize_mac(value)
|
||
elif SNMPService.OID_IF_ADMIN_STATUS in oid:
|
||
status_map = {1: 'up', 2: 'down', 3: 'testing'}
|
||
interface_data[if_index]['if_admin_status'] = status_map.get(int(value), 'unknown')
|
||
elif SNMPService.OID_IF_OPER_STATUS in oid:
|
||
status_map = {1: 'up', 2: 'down', 3: 'testing'}
|
||
interface_data[if_index]['if_oper_status'] = status_map.get(int(value), 'unknown')
|
||
|
||
# 转换为列表并保存
|
||
now = datetime.utcnow()
|
||
for if_index, data in interface_data.items():
|
||
interfaces.append(data)
|
||
|
||
existing = db.query(DeviceInterface).filter(
|
||
DeviceInterface.device_id == device.id,
|
||
DeviceInterface.if_index == int(if_index)
|
||
).first()
|
||
|
||
if existing:
|
||
for key, val in data.items():
|
||
if hasattr(existing, key):
|
||
setattr(existing, key, val)
|
||
existing.last_polled_at = now
|
||
else:
|
||
iface = DeviceInterface(
|
||
device_id=device.id,
|
||
if_index=int(if_index),
|
||
if_descr=data.get('if_descr'),
|
||
if_type=data.get('if_type'),
|
||
if_mtu=data.get('if_mtu'),
|
||
if_speed=data.get('if_speed'),
|
||
if_phys_address=data.get('if_phys_address'),
|
||
if_admin_status=data.get('if_admin_status'),
|
||
if_oper_status=data.get('if_oper_status'),
|
||
last_polled_at=now
|
||
)
|
||
db.add(iface)
|
||
|
||
db.commit()
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取接口信息失败: {e}", exc_info=True)
|
||
|
||
return interfaces
|
||
|
||
@staticmethod
|
||
def create_credential(db: Session, name: str, version: SNMPVersion, **kwargs) -> SNMPCredential:
|
||
"""创建 SNMP 凭据"""
|
||
credential = SNMPCredential(
|
||
name=name,
|
||
version=version,
|
||
**kwargs
|
||
)
|
||
db.add(credential)
|
||
db.commit()
|
||
db.refresh(credential)
|
||
return credential
|
||
|
||
@staticmethod
|
||
def create_device(db: Session, name: str, ip_address: str, credential_id: Optional[int] = None, **kwargs) -> NetworkDevice:
|
||
"""创建网络设备"""
|
||
device = NetworkDevice(
|
||
name=name,
|
||
ip_address=ip_address,
|
||
snmp_credential_id=credential_id,
|
||
**kwargs
|
||
)
|
||
db.add(device)
|
||
db.commit()
|
||
db.refresh(device)
|
||
return device
|
||
|
||
@staticmethod
|
||
def poll_device(db: Session, device_id: int) -> Dict[str, Any]:
|
||
"""轮询设备,获取所有信息"""
|
||
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
||
if not device:
|
||
return {'error': '设备不存在'}
|
||
|
||
if not device.snmp_credential:
|
||
return {'error': '设备未配置 SNMP 凭据'}
|
||
|
||
credential = device.snmp_credential
|
||
|
||
result = {
|
||
'device_id': device_id,
|
||
'device_name': device.name,
|
||
'arp_entries': [],
|
||
'mac_entries': [],
|
||
'interfaces': []
|
||
}
|
||
|
||
# 获取 ARP 表
|
||
arp_entries = SNMPService.get_arp_table(device, credential, db)
|
||
result['arp_entries'] = arp_entries
|
||
result['arp_count'] = len(arp_entries)
|
||
|
||
# 获取 MAC 地址表
|
||
mac_entries = SNMPService.get_mac_address_table(device, credential, db)
|
||
result['mac_entries'] = mac_entries
|
||
result['mac_count'] = len(mac_entries)
|
||
|
||
# 获取接口信息
|
||
interfaces = SNMPService.get_interfaces(device, credential, db)
|
||
result['interfaces'] = interfaces
|
||
result['interface_count'] = len(interfaces)
|
||
|
||
return result
|