Compare commits
13 Commits
5b93e4536f
...
11d830b496
| Author | SHA1 | Date | |
|---|---|---|---|
| 11d830b496 | |||
| 934b604b65 | |||
| 13c457acb3 | |||
| 05cd71ca1a | |||
| ee0552f837 | |||
| 094d2cc112 | |||
| 8b1df674ec | |||
| 6076e174d5 | |||
| 8a94f535de | |||
| ebb04f9ad8 | |||
| f66e4d41c9 | |||
| e0cba105b6 | |||
| a8e764b102 |
@@ -0,0 +1,75 @@
|
||||
# Python 3.10 兼容性说明
|
||||
|
||||
## 概述
|
||||
本分支 (python3.10) 专门针对 Python 3.10 环境优化,确保所有代码和依赖都能在 Python 3.10 下正常运行。
|
||||
|
||||
## 兼容性调整
|
||||
|
||||
### 已验证的兼容特性
|
||||
✅ 所有依赖包版本均支持 Python 3.10
|
||||
✅ 无 Python 3.11 特有语法(如 `tomllib`、`asyncio.TaskGroup`、`typing.Self` 等)
|
||||
✅ 类型注解完全兼容 Python 3.10
|
||||
✅ `match` 关键字未用作 Python 3.10 的模式匹配语句(仅用作 `re.match()` 的变量名)
|
||||
|
||||
### 重要依赖说明
|
||||
|
||||
#### pysnmp 兼容问题解决
|
||||
**问题**:`pysnmp==7.1.15` 存在以下兼容性问题:
|
||||
- 使用已废弃的 `asyncio.coroutine` 装饰器(Python 3.11 中已移除)
|
||||
- 与 Python 3.10+ 的 asyncio 不兼容
|
||||
|
||||
**解决方案**:
|
||||
使用社区维护的分支 `pysnmp-lextudio==5.0.31`
|
||||
- 完全向后兼容原有 pysnmp API
|
||||
- 支持 Python 3.6+
|
||||
- 积极维护,修复了 asyncio 兼容性问题
|
||||
- 导入方式完全不变
|
||||
|
||||
```python
|
||||
# 无需修改代码,导入方式保持一致
|
||||
from pysnmp.hlapi.asyncio import bulkCmd, getCmd, nextCmd
|
||||
```
|
||||
|
||||
#### pyasn1 版本冲突解决
|
||||
**问题**:`pyasn1>=0.5.0` 移除了 `pyasn1.compat.octets` 模块,导致 pysnmp 导入失败。
|
||||
同时 `python-jose==3.5.0` 强制要求 `pyasn1>=0.5.0`,形成版本冲突。
|
||||
|
||||
**解决方案**:
|
||||
- `pyasn1==0.4.8` - 锁定到包含 compat.octets 的版本
|
||||
- `pyasn1-modules==0.2.8` - 配套版本
|
||||
- `python-jose[cryptography]==3.3.0` - 兼容 pyasn1 0.4.8 的版本
|
||||
|
||||
### 依赖版本要求
|
||||
- `pysnmp-lextudio==5.0.31` - SNMP 协议兼容替代
|
||||
- `typing-extensions>=4.5.0` - 提供额外的类型支持
|
||||
- 所有核心依赖(FastAPI、SQLAlchemy、Pydantic 等)均已验证兼容
|
||||
|
||||
## 已知的 Python 3.11+ 特性未使用
|
||||
项目中未使用以下 Python 3.11+ 特有的功能:
|
||||
- `tomllib` 标准库(如有需要可使用 `tomli` 第三方库)
|
||||
- `asyncio.TaskGroup`
|
||||
- `typing.Self`
|
||||
- `ExceptionGroup` / `except*`
|
||||
- `StrEnum` / `IntEnum` 的新特性
|
||||
|
||||
## 安装说明
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 升级提示
|
||||
如果未来需要升级到 Python 3.11+,可以:
|
||||
1. 使用 `tomllib` 替代可能的 TOML 解析需求
|
||||
2. 考虑使用 `asyncio.TaskGroup` 改进异步代码结构
|
||||
3. 使用 `typing.Self` 简化类型注解
|
||||
|
||||
## 验证
|
||||
可以使用以下命令验证 Python 版本兼容性:
|
||||
```bash
|
||||
python --version # 应为 3.10.x 或 3.11.x
|
||||
python -c "import sys; assert sys.version_info >= (3, 10), 'Python 3.10+ required'"
|
||||
|
||||
# 验证 pysnmp 导入
|
||||
python -c "from pysnmp.hlapi.asyncio import bulkCmd, getCmd, nextCmd; print('pysnmp OK')"
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -248,6 +248,7 @@ def get_network_devices(
|
||||
# 补充手写的关联字段(datetime 已经由 serialize_dt_fields 序列化)
|
||||
item["device_type"] = d.device_type.value if hasattr(d.device_type, 'value') else d.device_type
|
||||
item["credential_name"] = cred_map.get(d.snmp_credential_id)
|
||||
item["snmp_credential_id"] = d.snmp_credential_id # 显式添加,确保前端能拿到
|
||||
item["last_polled_at"] = item.get("last_polled_at") # 已是 ISO 字符串
|
||||
item["last_successful_poll"] = item.get("last_successful_poll")
|
||||
item["arp_poll_interval"] = d.arp_poll_interval
|
||||
@@ -325,6 +326,7 @@ def update_network_device(
|
||||
name: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
snmp_credential_id: Optional[int] = None,
|
||||
clear_snmp_credential: bool = False, # 新增:专门的标志
|
||||
port: Optional[int] = None,
|
||||
device_type: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
@@ -350,7 +352,13 @@ def update_network_device(
|
||||
device.name = name
|
||||
if ip_address:
|
||||
device.ip_address = ip_address
|
||||
if snmp_credential_id is not None:
|
||||
# 处理 SNMP 凭据:
|
||||
# - clear_snmp_credential=True → 清除凭据
|
||||
# - snmp_credential_id > 0 → 设置为该值
|
||||
# - 否则(都不做任何操作
|
||||
if clear_snmp_credential:
|
||||
device.snmp_credential_id = None
|
||||
elif snmp_credential_id is not None and snmp_credential_id > 0:
|
||||
device.snmp_credential_id = snmp_credential_id
|
||||
if port:
|
||||
device.port = port
|
||||
@@ -429,7 +437,7 @@ def delete_network_device(
|
||||
# ========== SNMP 操作 ==========
|
||||
|
||||
@router.post("/devices/{device_id}/test", summary="测试 SNMP 连接")
|
||||
def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
|
||||
async def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
|
||||
"""测试设备的 SNMP 连接(不写审计,避免刷屏)"""
|
||||
from app.models.snmp import NetworkDevice
|
||||
|
||||
@@ -440,7 +448,7 @@ def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
|
||||
if not device.snmp_credential:
|
||||
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
|
||||
|
||||
success, info = SNMPService.test_connection(device, device.snmp_credential)
|
||||
success, info = await SNMPService.test_connection(device, device.snmp_credential)
|
||||
|
||||
return {
|
||||
"device_id": device_id,
|
||||
@@ -451,9 +459,9 @@ def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/poll", summary="立即轮询设备")
|
||||
def poll_device_now(device_id: int, db: Session = Depends(get_db)):
|
||||
async def poll_device_now(device_id: int, db: Session = Depends(get_db)):
|
||||
"""立即轮询设备,获取 ARP 表、MAC 地址表、接口信息(不写审计,常规操作)"""
|
||||
result = SNMPService.poll_device(db, device_id)
|
||||
result = await SNMPService.poll_device(db, device_id)
|
||||
|
||||
if 'error' in result:
|
||||
raise HTTPException(status_code=400, detail=result['error'])
|
||||
@@ -462,7 +470,7 @@ def poll_device_now(device_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/arp", summary="获取设备 ARP 表")
|
||||
def get_device_arp_table(device_id: int, db: Session = Depends(get_db)):
|
||||
async def get_device_arp_table(device_id: int, db: Session = Depends(get_db)):
|
||||
"""获取设备的 ARP 表"""
|
||||
from app.models.snmp import NetworkDevice, ARPEntry
|
||||
|
||||
@@ -473,7 +481,7 @@ def get_device_arp_table(device_id: int, db: Session = Depends(get_db)):
|
||||
if not device.snmp_credential:
|
||||
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
|
||||
|
||||
entries = SNMPService.get_arp_table(device, device.snmp_credential, db)
|
||||
entries = await SNMPService.get_arp_table(device, device.snmp_credential, db)
|
||||
|
||||
return {
|
||||
"device_id": device_id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,7 @@ import socket
|
||||
import ipaddress
|
||||
import re
|
||||
import logging
|
||||
import os
|
||||
|
||||
try:
|
||||
from mac_vendor_lookup import MacLookup, VendorNotFoundError
|
||||
@@ -20,6 +21,40 @@ from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OUI 数据库文件路径(相对于项目根)
|
||||
_OUI_DB_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'oui.txt')
|
||||
|
||||
|
||||
def _load_oui_database() -> Optional[Dict[str, str]]:
|
||||
"""从本地文件加载 OUI 数据库,返回 {prefix: vendor} 字典"""
|
||||
if not os.path.exists(_OUI_DB_FILE):
|
||||
logger.warning(f"OUI 数据库文件不存在: {_OUI_DB_FILE}")
|
||||
return None
|
||||
try:
|
||||
db = {}
|
||||
with open(_OUI_DB_FILE, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if ':' in line:
|
||||
prefix, vendor = line.split(':', 1)
|
||||
db[prefix] = vendor
|
||||
logger.info(f"已加载 OUI 数据库: {len(db)} 条记录, 文件: {_OUI_DB_FILE}")
|
||||
return db
|
||||
except Exception as e:
|
||||
logger.error(f"加载 OUI 数据库失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# 全局 OUI 数据库(延迟加载)
|
||||
_OUI_DB = None
|
||||
|
||||
|
||||
def _get_oui_db() -> Optional[Dict[str, str]]:
|
||||
global _OUI_DB
|
||||
if _OUI_DB is None:
|
||||
_OUI_DB = _load_oui_database()
|
||||
return _OUI_DB
|
||||
|
||||
|
||||
class EnhancedScanService:
|
||||
"""增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别、NetBIOS主机名发现"""
|
||||
@@ -45,13 +80,23 @@ class EnhancedScanService:
|
||||
if len(mac) < 8:
|
||||
return None
|
||||
|
||||
# 使用 mac_vendor_lookup 库(IEEE OUI 官方数据库)
|
||||
# 提取 OUI(前 6 位十六进制 = AA:BB:CC)
|
||||
oui = mac[:8] # "AA:BB:CC"
|
||||
|
||||
# 1️⃣ 优先使用本地 OUI 数据库(免网络、高性能)
|
||||
db = _get_oui_db()
|
||||
if db:
|
||||
# 数据库 key 是 AABBCC 格式(无冒号)
|
||||
oui_key = oui.replace(':', '')
|
||||
vendor = db.get(oui_key)
|
||||
if vendor:
|
||||
return vendor
|
||||
|
||||
# 2️⃣ 回退:mac_vendor_lookup 在线库
|
||||
if MAC_LOOKUP_AVAILABLE:
|
||||
lookup = EnhancedScanService._get_mac_lookup()
|
||||
if lookup is not None:
|
||||
try:
|
||||
# 提取 OUI(前 3 字节)
|
||||
oui = mac[:8] # "AA:BB:CC"
|
||||
vendor = lookup.lookup(oui)
|
||||
if vendor:
|
||||
return vendor
|
||||
@@ -60,7 +105,7 @@ class EnhancedScanService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 回退:硬编码常用厂商映射
|
||||
# 3️⃣ 回退:硬编码常用厂商映射
|
||||
return EnhancedScanService._legacy_oui_lookup(mac)
|
||||
|
||||
# 硬编码回退映射(mac_vendor_lookup 不可用时使用)
|
||||
|
||||
@@ -14,9 +14,9 @@ class NetworkService:
|
||||
|
||||
@staticmethod
|
||||
def calculate_total_ips(cidr: str) -> int:
|
||||
"""计算网段的总IP数量"""
|
||||
"""计算网段中实际分配的IP数量(排除网络地址和广播地址)"""
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
return network.num_addresses
|
||||
return network.num_addresses - 2 if network.num_addresses > 2 else network.num_addresses
|
||||
|
||||
@staticmethod
|
||||
def get_network_addresses(cidr: str) -> List[str]:
|
||||
|
||||
@@ -3,8 +3,9 @@ from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import ipaddress
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from pysnmp.hlapi import (
|
||||
from pysnmp.hlapi.asyncio import (
|
||||
SnmpEngine, CommunityData, UsmUserData,
|
||||
UdpTransportTarget, ContextData,
|
||||
ObjectType, ObjectIdentity,
|
||||
@@ -117,7 +118,7 @@ class SNMPService:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def test_connection(device: NetworkDevice, credential: SNMPCredential) -> Tuple[bool, Dict[str, Any]]:
|
||||
async def test_connection(device: NetworkDevice, credential: SNMPCredential) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
测试 SNMP 连接
|
||||
返回: (成功, 设备信息字典)
|
||||
@@ -131,11 +132,11 @@ class SNMPService:
|
||||
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)))
|
||||
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:
|
||||
@@ -162,7 +163,7 @@ class SNMPService:
|
||||
return False, {'error': str(e)}
|
||||
|
||||
@staticmethod
|
||||
def get_arp_table(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||||
async def get_arp_table(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取 ARP 表 (IP -> MAC 映射)
|
||||
"""
|
||||
@@ -178,61 +179,60 @@ class SNMPService:
|
||||
)
|
||||
|
||||
# 使用 bulkCmd 获取 ARP 表
|
||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||||
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}")
|
||||
break
|
||||
)
|
||||
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]
|
||||
|
||||
if errorStatus:
|
||||
logger.error(f"SNMP 错误: {errorStatus}")
|
||||
break
|
||||
# 提取 IP 地址 (OID 后缀: ifIndex.IP)
|
||||
parts = oid.split('.')
|
||||
if len(parts) >= 4:
|
||||
# 从 OID 中提取 IP 地址
|
||||
ip_parts = parts[-4:]
|
||||
ip_address = '.'.join(ip_parts)
|
||||
|
||||
# 解析结果
|
||||
arp_data = {}
|
||||
for varBind in varBinds:
|
||||
oid = str(varBind[0])
|
||||
value = varBind[1]
|
||||
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
|
||||
|
||||
# 提取 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)
|
||||
# 转换为列表
|
||||
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:
|
||||
@@ -258,16 +258,18 @@ class SNMPService:
|
||||
|
||||
db.commit()
|
||||
|
||||
# 更新 IP 资产台账中的 MAC 地址
|
||||
# 更新 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'])
|
||||
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
|
||||
@@ -281,7 +283,7 @@ class SNMPService:
|
||||
return arp_entries
|
||||
|
||||
@staticmethod
|
||||
def get_mac_address_table(device: NetworkDevice, credential: SNMPCredential, db: Session, vlan_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
async def get_mac_address_table(device: NetworkDevice, credential: SNMPCredential, db: Session, vlan_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取 MAC 地址表 (MAC -> 端口 映射)
|
||||
"""
|
||||
@@ -297,39 +299,39 @@ class SNMPService:
|
||||
)
|
||||
|
||||
# 获取 MAC 地址表
|
||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||||
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:
|
||||
break
|
||||
)
|
||||
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]
|
||||
|
||||
if errorStatus:
|
||||
break
|
||||
# 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)
|
||||
|
||||
for varBind in varBinds:
|
||||
oid = str(varBind[0])
|
||||
value = varBind[1]
|
||||
if SNMPService.OID_DOT1D_TP_FDB_PORT in oid:
|
||||
port = int(value) if isinstance(value, Integer32) else None
|
||||
|
||||
# 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)
|
||||
entry = {
|
||||
'mac_address': mac_address,
|
||||
'port_number': port,
|
||||
'device_id': device.id,
|
||||
'vlan_id': vlan_id
|
||||
}
|
||||
mac_entries.append(entry)
|
||||
|
||||
# 保存到数据库
|
||||
now = datetime.utcnow()
|
||||
@@ -361,7 +363,7 @@ class SNMPService:
|
||||
return mac_entries
|
||||
|
||||
@staticmethod
|
||||
def get_interfaces(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||||
async def get_interfaces(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取设备接口信息
|
||||
"""
|
||||
@@ -379,7 +381,7 @@ class SNMPService:
|
||||
# 获取接口信息
|
||||
interface_data = {}
|
||||
|
||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||||
errorIndication, errorStatus, errorIndex, varBindTable = await bulkCmd(
|
||||
SnmpEngine(), auth_data, transport, ContextData(),
|
||||
0, 100,
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_DESCR)),
|
||||
@@ -390,35 +392,34 @@ class SNMPService:
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_ADMIN_STATUS)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_OPER_STATUS)),
|
||||
lexicographicMode=False
|
||||
):
|
||||
if errorIndication or errorStatus:
|
||||
break
|
||||
)
|
||||
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]
|
||||
|
||||
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 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')
|
||||
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()
|
||||
@@ -485,7 +486,7 @@ class SNMPService:
|
||||
return device
|
||||
|
||||
@staticmethod
|
||||
def poll_device(db: Session, device_id: int) -> Dict[str, Any]:
|
||||
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:
|
||||
@@ -505,17 +506,17 @@ class SNMPService:
|
||||
}
|
||||
|
||||
# 获取 ARP 表
|
||||
arp_entries = SNMPService.get_arp_table(device, credential, db)
|
||||
arp_entries = await 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)
|
||||
mac_entries = await 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)
|
||||
interfaces = await SNMPService.get_interfaces(device, credential, db)
|
||||
result['interfaces'] = interfaces
|
||||
result['interface_count'] = len(interfaces)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Python 3.10+ 兼容版本
|
||||
fastapi==0.110.0
|
||||
uvicorn[standard]==0.27.1
|
||||
sqlalchemy==2.0.28
|
||||
@@ -9,9 +10,18 @@ celery==5.3.6
|
||||
redis==5.0.3
|
||||
python-multipart==0.0.9
|
||||
alembic==1.13.1
|
||||
pysnmp==7.1.15
|
||||
# 注意: pysnmp 7.x 与 Python 3.10+/asyncio 存在兼容性问题
|
||||
# 使用 lextudio 维护的分支(向后兼容,支持 Python 3.6+)
|
||||
pysnmp-lextudio==5.0.31
|
||||
# pyasn1 0.5.0+ 移除了 compat.octets 模块,与 pysnmp 不兼容
|
||||
pyasn1==0.4.8
|
||||
pyasn1-modules==0.2.8
|
||||
scapy==2.5.0
|
||||
python-dotenv==1.0.1
|
||||
passlib[bcrypt]==1.7.4
|
||||
httpx==0.27.0
|
||||
python-jose[cryptography]
|
||||
# python-jose 3.5.0 需要 pyasn1>=0.5.0,与 pysnmp 冲突
|
||||
python-jose[cryptography]==3.3.0
|
||||
bcrypt==3.2.2
|
||||
# typing-extensions 提供 Python 3.10+ 的向后兼容支持
|
||||
typing-extensions>=4.5.0
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
修复已有网段的 total_ips 字段:将 num_addresses 改为 hosts 数量(排除网络地址和广播地址)
|
||||
|
||||
用法: cd /root/ipam/backend && source venv/bin/activate && python scripts/fix_network_total_ips.py
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 确保能找到 app 模块
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.network import Network
|
||||
from app.services.network_service import NetworkService
|
||||
|
||||
|
||||
def fix_total_ips():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
networks = db.query(Network).all()
|
||||
fixed = 0
|
||||
for net in networks:
|
||||
correct = NetworkService.calculate_total_ips(net.cidr)
|
||||
actual_hosts = len(list(NetworkService.get_network_addresses(net.cidr)))
|
||||
if net.total_ips != correct:
|
||||
print(f" [{net.cidr:20s}] {net.total_ips:>5} -> {correct:>5} (hosts={actual_hosts})")
|
||||
net.total_ips = correct
|
||||
fixed += 1
|
||||
db.commit()
|
||||
print(f"\n✅ 已修复 {fixed} 个网段")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"❌ 错误: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fix_total_ips()
|
||||
@@ -4,9 +4,10 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"dev": "node node_modules/vite/bin/vite.js",
|
||||
"dev:clean": "rm -rf node_modules/.vite && node node_modules/vite/bin/vite.js --force",
|
||||
"build": "node node_modules/vite/bin/vite.js build",
|
||||
"preview": "node node_modules/vite/bin/vite.js preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
|
||||
@@ -398,7 +398,13 @@ const saveDevice = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isDeviceEdit.value) {
|
||||
await snmpApi.updateDevice(deviceForm.id, deviceForm)
|
||||
// 处理清除凭据:snmp_credential_id=null 时发送 clear_snmp_credential=true
|
||||
const updateData = { ...deviceForm }
|
||||
if (updateData.snmp_credential_id === null) {
|
||||
delete updateData.snmp_credential_id
|
||||
updateData.clear_snmp_credential = true
|
||||
}
|
||||
await snmpApi.updateDevice(deviceForm.id, updateData)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await snmpApi.createDevice(deviceForm)
|
||||
|
||||
@@ -138,15 +138,41 @@ start_frontend() {
|
||||
|
||||
cd $FRONTEND_DIR
|
||||
|
||||
# 检查 node_modules
|
||||
if [ ! -d "node_modules" ]; then
|
||||
# 检查 node_modules 及关键依赖是否完整
|
||||
if [ ! -d "node_modules" ] || [ ! -f "node_modules/element-plus/es/index.mjs" ]; then
|
||||
echo -e "${YELLOW}安装前端依赖...${NC}"
|
||||
npm install
|
||||
fi
|
||||
|
||||
# 检查 OUI 数据库文件
|
||||
if [ ! -f "$BACKEND_DIR/app/data/oui.txt" ]; then
|
||||
echo -e "${YELLOW}OUI 数据库不存在,尝试从 IEEE 下载...${NC}"
|
||||
cd $BACKEND_DIR
|
||||
source venv/bin/activate
|
||||
python3 -c "
|
||||
from mac_vendor_lookup import MacLookup
|
||||
import os
|
||||
os.makedirs('app/data', exist_ok=True)
|
||||
lookup = MacLookup()
|
||||
lookup.update_vendors()
|
||||
# 复制到项目目录
|
||||
import shutil
|
||||
src = os.path.expanduser('~/.cache/mac-vendors.txt')
|
||||
if os.path.exists(src):
|
||||
shutil.copy(src, 'app/data/oui.txt')
|
||||
print('OUI 数据库下载完成')
|
||||
" 2>/dev/null || echo -e "${YELLOW}OUI 数据库下载失败(网络问题),使用内置硬编码回退${NC}"
|
||||
fi
|
||||
|
||||
# 检查端口并清理
|
||||
kill_port $FRONTEND_PORT
|
||||
|
||||
# 清理 Vite 缓存,避免 element-plus exports 解析失败
|
||||
if [ -d "node_modules/.vite" ]; then
|
||||
echo -e "${YELLOW}清理 Vite 缓存...${NC}"
|
||||
rm -rf node_modules/.vite
|
||||
fi
|
||||
|
||||
# 后台启动前端
|
||||
nohup npm run dev -- --host 0.0.0.0 --port $FRONTEND_PORT > /tmp/ipam-frontend.log 2>&1 &
|
||||
FRONTEND_PID=$!
|
||||
@@ -159,9 +185,24 @@ start_frontend() {
|
||||
echo " 日志: /tmp/ipam-frontend.log"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ 前端服务启动失败${NC}"
|
||||
echo " 查看日志: tail -50 /tmp/ipam-frontend.log"
|
||||
return 1
|
||||
# 如果启动失败,尝试清缓存重试一次
|
||||
echo -e "${YELLOW}首次启动失败,尝试清理 Vite 缓存重试...${NC}"
|
||||
kill -9 $FRONTEND_PID 2>/dev/null
|
||||
sleep 1
|
||||
rm -rf node_modules/.vite
|
||||
nohup npm run dev -- --host 0.0.0.0 --port $FRONTEND_PORT > /tmp/ipam-frontend.log 2>&1 &
|
||||
FRONTEND_PID=$!
|
||||
sleep 12
|
||||
if curl -s http://localhost:$FRONTEND_PORT > /dev/null; then
|
||||
echo -e "${GREEN}✅ 前端服务启动成功 (端口 $FRONTEND_PORT)${NC}"
|
||||
echo " PID: $FRONTEND_PID"
|
||||
echo " 日志: /tmp/ipam-frontend.log"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ 前端服务启动失败${NC}"
|
||||
echo " 查看日志: tail -50 /tmp/ipam-frontend.log"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user