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 记录
This commit is contained in:
Your Name
2026-07-18 09:45:09 +08:00
commit 1e1e5957e8
103 changed files with 13196 additions and 0 deletions
+520
View File
@@ -0,0 +1,520 @@
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 凭据缺少 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
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