Files
ipam/backend/app/services/snmp_service.py
T
Your Name 11d830b496 fix(snmp): 修复 SNMP 采集 ARP 表时只补 MAC 不补厂商的问题
问题原因:
- snmp_service.py get_arp_table 第267行条件为
  if ip_addr and not ip_addr.mac_address:
- 只给原本没有 MAC 的 IP 设置 MAC + 厂商
- 如果 IP 已有 MAC(来自 Ping/ARP 扫描)但没有 vendor,
  则永远不会补全厂商

解决方案:
- 拆分为两个 if:
  1. if not ip_addr.mac_address: 写入 MAC
  2. if not ip_addr.vendor and ip_addr.mac_address: 补全厂商
- 确保已有 MAC 但无厂商的 IP 也能被补全
- 已有 backfill_vendor.py 脚本可批量回填历史数据
2026-07-23 15:39:59 +08:00

524 lines
21 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, Tuple
from sqlalchemy.orm import Session
from datetime import datetime
import ipaddress
import logging
import asyncio
from pysnmp.hlapi.asyncio 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 凭据缺少 usernameV3 强制要求 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
async 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 = await 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
async 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 表
errorIndication, errorStatus, errorIndex, varBindTable = await 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}")
elif errorStatus:
logger.error(f"SNMP 错误: {errorStatus}")
else:
# varBindTable 是列表的列表,每行是一条 SNMP 响应记录
for varBinds in varBindTable:
# 解析结果
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
# 转换为列表
now = datetime.utcnow()
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,
'last_seen': now.isoformat(),
'discovered_at': now.isoformat()
}
arp_entries.append(entry)
# 保存到数据库
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:
if not ip_addr.mac_address:
ip_addr.mac_address = entry['mac_address']
# 始终尝试补全厂商(有 MAC 但没厂商时也需要)
if not ip_addr.vendor and ip_addr.mac_address:
ip_addr.vendor = EnhancedScanService.get_mac_vendor(ip_addr.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
async 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 地址表
errorIndication, errorStatus, errorIndex, varBindTable = await 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:
logger.error(f"获取 MAC 地址表失败: {errorIndication}")
elif errorStatus:
logger.error(f"获取 MAC 地址表失败: {errorStatus}")
else:
for varBinds in varBindTable:
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
async 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 = {}
errorIndication, errorStatus, errorIndex, varBindTable = await 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 not errorIndication and not errorStatus:
for varBinds in varBindTable:
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
async 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 = await SNMPService.get_arp_table(device, credential, db)
result['arp_entries'] = arp_entries
result['arp_count'] = len(arp_entries)
# 获取 MAC 地址表
mac_entries = await SNMPService.get_mac_address_table(device, credential, db)
result['mac_entries'] = mac_entries
result['mac_count'] = len(mac_entries)
# 获取接口信息
interfaces = await SNMPService.get_interfaces(device, credential, db)
result['interfaces'] = interfaces
result['interface_count'] = len(interfaces)
return result