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
+496
View File
@@ -0,0 +1,496 @@
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
import logging
import json
from app.models.alert import (
Alert, AlertRule, WhitelistedMAC,
AlertType, AlertSeverity, AlertStatus
)
from app.models.network import Network, IPAddress
from app.models.snmp import NetworkDevice, ARPEntry
logger = logging.getLogger(__name__)
class AlertService:
"""告警检测与管理服务"""
@staticmethod
def create_alert(db: Session,
alert_type: AlertType,
title: str,
message: str = "",
severity: AlertSeverity = AlertSeverity.WARNING,
network_id: Optional[int] = None,
ip_address_id: Optional[int] = None,
device_id: Optional[int] = None,
ip_address_str: Optional[str] = None,
mac_address: Optional[str] = None,
conflicting_mac: Optional[str] = None,
usage_percent: Optional[float] = None) -> Alert:
"""创建告警"""
# 检查是否已有相同的活跃告警,避免重复告警
existing = db.query(Alert).filter(
Alert.alert_type == alert_type,
Alert.status == AlertStatus.ACTIVE
)
if ip_address_str:
existing = existing.filter(Alert.ip_address_str == ip_address_str)
if network_id:
existing = existing.filter(Alert.network_id == network_id)
if device_id:
existing = existing.filter(Alert.device_id == device_id)
existing = existing.first()
if existing:
logger.debug(f"已有相同的活跃告警: {alert_type} - {ip_address_str or network_id or device_id}")
return existing
alert = Alert(
alert_type=alert_type,
title=title,
message=message,
severity=severity,
network_id=network_id,
ip_address_id=ip_address_id,
device_id=device_id,
ip_address_str=ip_address_str,
mac_address=mac_address,
conflicting_mac=conflicting_mac,
usage_percent=usage_percent
)
db.add(alert)
db.commit()
db.refresh(alert)
logger.info(f"创建告警: {alert_type} - {title}")
return alert
@staticmethod
def detect_ip_conflicts(db: Session) -> List[Dict[str, Any]]:
"""检测 IP 冲突"""
conflicts = []
# 检查 ARP 表中同一个 IP 对应多个不同 MAC 的情况
subquery = db.query(
ARPEntry.ip_address
).group_by(ARPEntry.ip_address).having(
db.func.count(db.func.distinct(ARPEntry.mac_address)) > 1
).subquery()
conflict_entries = db.query(ARPEntry).filter(
ARPEntry.ip_address.in_(subquery)
).order_by(ARPEntry.ip_address).all()
# 按 IP 分组
ip_macs = {}
for entry in conflict_entries:
if entry.ip_address not in ip_macs:
ip_macs[entry.ip_address] = set()
ip_macs[entry.ip_address].add(entry.mac_address)
# 为每个冲突创建告警
for ip, macs in ip_macs.items():
if len(macs) > 1:
mac_list = list(macs)
conflict_info = {
'ip_address': ip,
'mac_addresses': mac_list
}
conflicts.append(conflict_info)
# 创建告警
AlertService.create_alert(
db=db,
alert_type=AlertType.IP_CONFLICT,
title=f"IP 冲突检测: {ip}",
message=f"IP 地址 {ip} 检测到多个 MAC 地址: {', '.join(mac_list)}",
severity=AlertSeverity.ERROR,
ip_address_str=ip,
mac_address=mac_list[0],
conflicting_mac=mac_list[1] if len(mac_list) > 1 else None
)
return conflicts
@staticmethod
def detect_unauthorized_access(db: Session) -> List[Dict[str, Any]]:
"""检测未授权接入(新 MAC 地址不在白名单中)"""
unauthorized = []
# 获取白名单 MAC
whitelist = set()
whitelist_entries = db.query(WhitelistedMAC).filter(
WhitelistedMAC.is_active == True
).all()
for entry in whitelist_entries:
whitelist.add(entry.mac_address.upper())
# 获取所有 ARP 表中的 MAC 地址
arp_entries = db.query(ARPEntry).all()
for entry in arp_entries:
if not entry.mac_address:
continue
mac = entry.mac_address.upper()
if mac and mac not in whitelist:
# 检查是否是最近发现的(1小时内)
is_recent = entry.discovered_at >= datetime.utcnow() - timedelta(hours=1)
if is_recent:
unauth_info = {
'ip_address': entry.ip_address,
'mac_address': mac,
'discovered_at': entry.discovered_at
}
unauthorized.append(unauth_info)
# 创建告警
AlertService.create_alert(
db=db,
alert_type=AlertType.UNAUTHORIZED_ACCESS,
title=f"未授权接入: {mac}",
message=f"检测到未授权设备接入: IP {entry.ip_address}, MAC {mac}",
severity=AlertSeverity.WARNING,
ip_address_str=entry.ip_address,
mac_address=mac
)
return unauthorized
@staticmethod
def detect_subnet_exhaustion(db: Session) -> List[Dict[str, Any]]:
"""检测网段耗尽"""
exhausted = []
# 获取所有网段的统计信息
networks = db.query(Network).all()
for network in networks:
if network.total_ips == 0:
continue
# 计算使用中的 IP 数量(在线或已分配)
used_count = db.query(IPAddress).filter(
IPAddress.network_id == network.id
).filter(
(IPAddress.mac_address.isnot(None)) |
(IPAddress.hostname.isnot(None))
).count()
usage_percent = (used_count / network.total_ips) * 100
# 默认阈值 90%
threshold = 90.0
if usage_percent >= threshold:
exhaustion_info = {
'network_id': network.id,
'cidr': network.cidr,
'name': network.name,
'total_ips': network.total_ips,
'used_ips': used_count,
'usage_percent': round(usage_percent, 2)
}
exhausted.append(exhaustion_info)
# 创建告警
AlertService.create_alert(
db=db,
alert_type=AlertType.SUBNET_FULL,
title=f"网段使用率告警: {network.cidr}",
message=f"网段 {network.cidr} ({network.name}) 使用率达到 {round(usage_percent, 2)}%,总IP {network.total_ips},已使用 {used_count}",
severity=AlertSeverity.WARNING if usage_percent < 95 else AlertSeverity.CRITICAL,
network_id=network.id,
usage_percent=usage_percent
)
return exhausted
@staticmethod
def detect_new_devices(db: Session) -> List[Dict[str, Any]]:
"""检测新发现的设备"""
new_devices = []
# 查找最近 1 小时内发现的新 MAC 地址
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
recent_entries = db.query(ARPEntry).filter(
ARPEntry.discovered_at >= one_hour_ago
).all()
for entry in recent_entries:
# 检查此 MAC 是否是新出现的
older_entries = db.query(ARPEntry).filter(
ARPEntry.mac_address == entry.mac_address,
ARPEntry.discovered_at < one_hour_ago
).count()
if older_entries == 0 and entry.mac_address:
device_info = {
'ip_address': entry.ip_address,
'mac_address': entry.mac_address,
'discovered_at': entry.discovered_at
}
new_devices.append(device_info)
# 创建告警
AlertService.create_alert(
db=db,
alert_type=AlertType.NEW_DEVICE_DETECTED,
title=f"新设备发现: {entry.ip_address}",
message=f"检测到新设备: IP {entry.ip_address}, MAC {entry.mac_address}",
severity=AlertSeverity.INFO,
ip_address_str=entry.ip_address,
mac_address=entry.mac_address
)
return new_devices
@staticmethod
def detect_device_offline(db: Session) -> List[Dict[str, Any]]:
"""检测设备离线"""
offline_devices = []
# 检查网络设备是否长时间没有成功轮询
threshold_time = datetime.utcnow() - timedelta(hours=2)
offline_network_devices = db.query(NetworkDevice).filter(
NetworkDevice.is_active == True,
(NetworkDevice.last_successful_poll < threshold_time) |
(NetworkDevice.last_successful_poll == None)
).all()
for device in offline_network_devices:
offline_info = {
'device_id': device.id,
'device_name': device.name,
'ip_address': device.ip_address,
'last_polled_at': device.last_polled_at,
'last_successful_poll': device.last_successful_poll
}
offline_devices.append(offline_info)
# 创建告警
AlertService.create_alert(
db=db,
alert_type=AlertType.DEVICE_OFFLINE,
title=f"设备离线: {device.name}",
message=f"网络设备 {device.name} ({device.ip_address}) 已超过 2 小时未成功轮询",
severity=AlertSeverity.ERROR,
device_id=device.id
)
return offline_devices
@staticmethod
def run_all_detections(db: Session) -> Dict[str, Any]:
"""运行所有检测"""
results = {}
logger.info("开始运行告警检测...")
# 检测 IP 冲突
conflicts = AlertService.detect_ip_conflicts(db)
results['ip_conflicts'] = {
'count': len(conflicts),
'items': conflicts
}
# 检测未授权接入
unauthorized = AlertService.detect_unauthorized_access(db)
results['unauthorized_access'] = {
'count': len(unauthorized),
'items': unauthorized
}
# 检测网段耗尽
subnet_exhaustion = AlertService.detect_subnet_exhaustion(db)
results['subnet_exhaustion'] = {
'count': len(subnet_exhaustion),
'items': subnet_exhaustion
}
# 检测新设备
new_devices = AlertService.detect_new_devices(db)
results['new_devices'] = {
'count': len(new_devices),
'items': new_devices
}
# 检测设备离线
offline_devices = AlertService.detect_device_offline(db)
results['offline_devices'] = {
'count': len(offline_devices),
'items': offline_devices
}
total_alerts = sum(v['count'] for v in results.values())
logger.info(f"告警检测完成,共发现 {total_alerts} 个问题")
return results
@staticmethod
def get_alerts(db: Session,
status: Optional[AlertStatus] = None,
severity: Optional[AlertSeverity] = None,
alert_type: Optional[AlertType] = None,
skip: int = 0,
limit: int = 100) -> tuple[int, List[Alert]]:
"""获取告警列表"""
query = db.query(Alert)
if status:
query = query.filter(Alert.status == status)
if severity:
query = query.filter(Alert.severity == severity)
if alert_type:
query = query.filter(Alert.alert_type == alert_type)
total = query.count()
items = query.order_by(Alert.created_at.desc()).offset(skip).limit(limit).all()
return total, items
@staticmethod
def acknowledge_alert(db: Session, alert_id: int, acknowledged_by: str = "system") -> Optional[Alert]:
"""确认告警"""
alert = db.query(Alert).filter(Alert.id == alert_id).first()
if not alert:
return None
alert.status = AlertStatus.ACKNOWLEDGED
alert.acknowledged_by = acknowledged_by
alert.acknowledged_at = datetime.utcnow()
db.commit()
db.refresh(alert)
return alert
@staticmethod
def resolve_alert(db: Session, alert_id: int, resolved_by: str = "system", notes: str = "") -> Optional[Alert]:
"""解决告警"""
alert = db.query(Alert).filter(Alert.id == alert_id).first()
if not alert:
return None
alert.status = AlertStatus.RESOLVED
alert.resolved_by = resolved_by
alert.resolved_at = datetime.utcnow()
alert.resolution_notes = notes
db.commit()
db.refresh(alert)
return alert
@staticmethod
def ignore_alert(db: Session, alert_id: int) -> Optional[Alert]:
"""忽略告警"""
alert = db.query(Alert).filter(Alert.id == alert_id).first()
if not alert:
return None
alert.status = AlertStatus.IGNORED
db.commit()
db.refresh(alert)
return alert
@staticmethod
def get_statistics(db: Session) -> Dict[str, Any]:
"""获取告警统计"""
active_count = db.query(Alert).filter(Alert.status == AlertStatus.ACTIVE).count()
acknowledged_count = db.query(Alert).filter(Alert.status == AlertStatus.ACKNOWLEDGED).count()
resolved_count = db.query(Alert).filter(Alert.status == AlertStatus.RESOLVED).count()
# 按严重级别统计
critical_count = db.query(Alert).filter(
Alert.status == AlertStatus.ACTIVE,
Alert.severity == AlertSeverity.CRITICAL
).count()
error_count = db.query(Alert).filter(
Alert.status == AlertStatus.ACTIVE,
Alert.severity == AlertSeverity.ERROR
).count()
warning_count = db.query(Alert).filter(
Alert.status == AlertStatus.ACTIVE,
Alert.severity == AlertSeverity.WARNING
).count()
info_count = db.query(Alert).filter(
Alert.status == AlertStatus.ACTIVE,
Alert.severity == AlertSeverity.INFO
).count()
# 按类型统计
type_stats = {}
for alert_type in AlertType:
count = db.query(Alert).filter(
Alert.status == AlertStatus.ACTIVE,
Alert.alert_type == alert_type
).count()
type_stats[alert_type.value] = count
return {
'by_status': {
'active': active_count,
'acknowledged': acknowledged_count,
'resolved': resolved_count
},
'by_severity': {
'critical': critical_count,
'error': error_count,
'warning': warning_count,
'info': info_count
},
'by_type': type_stats
}
@staticmethod
def add_mac_to_whitelist(db: Session, mac_address: str, description: str = "", owner: str = "") -> WhitelistedMAC:
"""添加 MAC 地址到白名单"""
existing = db.query(WhitelistedMAC).filter(WhitelistedMAC.mac_address == mac_address.upper()).first()
if existing:
existing.is_active = True
if description:
existing.description = description
if owner:
existing.owner = owner
db.commit()
db.refresh(existing)
return existing
whitelisted_mac = WhitelistedMAC(
mac_address=mac_address.upper(),
description=description,
owner=owner
)
db.add(whitelisted_mac)
db.commit()
db.refresh(whitelisted_mac)
return whitelisted_mac
@staticmethod
def get_mac_whitelist(db: Session, skip: int = 0, limit: int = 100) -> tuple[int, List[WhitelistedMAC]]:
"""获取 MAC 白名单"""
query = db.query(WhitelistedMAC).filter(WhitelistedMAC.is_active == True)
total = query.count()
items = query.order_by(WhitelistedMAC.id.desc()).offset(skip).limit(limit).all()
return total, items
@staticmethod
def remove_mac_from_whitelist(db: Session, mac_id: int) -> bool:
"""从白名单移除 MAC 地址"""
mac = db.query(WhitelistedMAC).filter(WhitelistedMAC.id == mac_id).first()
if not mac:
return False
mac.is_active = False
db.commit()
return True
+155
View File
@@ -0,0 +1,155 @@
from typing import Optional, Dict, Any, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import desc
from datetime import datetime
import json
from app.models.audit import AuditLog
from app.models.auth import User
class AuditService:
"""审计日志服务"""
@staticmethod
def record(
db: Session,
*,
action: str,
user: Optional[User] = None,
resource_type: Optional[str] = None,
resource_id: Optional[str] = None,
resource_name: Optional[str] = None,
method: Optional[str] = None,
path: Optional[str] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
status: str = "success",
detail: Optional[Any] = None,
) -> AuditLog:
"""
写入一条审计日志。任意字段缺失都安全降级(不抛异常),
避免审计日志写入失败影响主业务流程。
"""
try:
log = AuditLog(
user_id=user.id if user else None,
username=user.username if user else None,
action=action,
resource_type=resource_type,
resource_id=str(resource_id) if resource_id is not None else None,
resource_name=resource_name,
method=method,
path=path,
ip_address=ip_address,
user_agent=(user_agent or "")[:500],
status=status,
detail=_serialize_detail(detail),
)
db.add(log)
db.commit()
return log
except Exception as e:
# 写审计日志失败不能影响主业务,仅回滚
db.rollback()
# 用 print 而非 logger,避免循环依赖(logger 可能未初始化)
print(f"[audit] failed to write log action={action}: {e}")
return None
@staticmethod
def get_logs(
db: Session,
*,
user_id: Optional[int] = None,
username: Optional[str] = None,
action: Optional[str] = None,
resource_type: Optional[str] = None,
status: Optional[str] = None,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
skip: int = 0,
limit: int = 50,
) -> Tuple[int, List[AuditLog]]:
query = db.query(AuditLog)
if user_id is not None:
query = query.filter(AuditLog.user_id == user_id)
if username:
query = query.filter(AuditLog.username.like(f"%{username}%"))
if action:
query = query.filter(AuditLog.action.like(f"%{action}%"))
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
if status:
query = query.filter(AuditLog.status == status)
if start_time:
query = query.filter(AuditLog.created_at >= start_time)
if end_time:
query = query.filter(AuditLog.created_at <= end_time)
total = query.count()
items = query.order_by(desc(AuditLog.created_at)).offset(skip).limit(limit).all()
return total, items
@staticmethod
def get_stats(db: Session) -> Dict[str, Any]:
"""首页/汇总用:最近 24h 操作数 + 按 action 分组"""
from datetime import timedelta
cutoff = datetime.utcnow() - timedelta(hours=24)
recent = db.query(AuditLog).filter(AuditLog.created_at >= cutoff).count()
by_action = db.query(AuditLog.action, db.query(AuditLog).filter(AuditLog.created_at >= cutoff).subquery()) # 占位避免循环
# 简化:用 group by
from sqlalchemy import func
rows = db.query(AuditLog.action, func.count(AuditLog.id)).filter(
AuditLog.created_at >= cutoff
).group_by(AuditLog.action).all()
return {
"recent_24h": recent,
"by_action_24h": {action: count for action, count in rows},
}
def _serialize_detail(detail: Any) -> Optional[str]:
"""把 dict/list 等结构化数据序列化成字符串,便于存储和展示"""
if detail is None:
return None
if isinstance(detail, str):
return detail[:4000]
try:
return json.dumps(detail, ensure_ascii=False, default=str)[:4000]
except (TypeError, ValueError):
return str(detail)[:4000]
# 常用 action 字符串集中管理(避免散落各处的魔法字符串)
class AuditAction:
# 网络
NETWORK_CREATE = "network.create"
NETWORK_UPDATE = "network.update"
NETWORK_DELETE = "network.delete"
# IP
IP_UPDATE = "ip.update"
IP_RESERVE = "ip.reserve"
IP_UNRESERVE = "ip.unreserve"
# 扫描
SCAN_TRIGGER = "scan.trigger"
# SNMP
SNMP_CRED_CREATE = "snmp.credential.create"
SNMP_CRED_UPDATE = "snmp.credential.update"
SNMP_CRED_DELETE = "snmp.credential.delete"
SNMP_DEVICE_CREATE = "snmp.device.create"
SNMP_DEVICE_UPDATE = "snmp.device.update"
SNMP_DEVICE_DELETE = "snmp.device.delete"
# 告警
ALERT_ACK = "alert.acknowledge"
ALERT_RESOLVE = "alert.resolve"
# 用户管理
USER_LOGIN = "user.login"
USER_LOGIN_FAILED = "user.login.failed"
USER_LOGOUT = "user.logout"
USER_CREATE = "user.create"
USER_UPDATE_STATUS = "user.update_status"
USER_UNLOCK = "user.unlock"
USER_RESET_PASSWORD = "user.reset_password"
USER_DELETE = "user.delete"
USER_CHANGE_PASSWORD = "user.change_password"
@@ -0,0 +1,381 @@
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)
}
+143
View File
@@ -0,0 +1,143 @@
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session
from datetime import datetime
from app.models.network import IPAddress as IPAddressModel
from app.models.network import Network as NetworkModel
from app.models.network import IPStatus
from app.schemas.network import IPAddressUpdate, IPAddressCreate
class IPService:
"""IP地址管理服务"""
@staticmethod
def get_by_id(db: Session, ip_id: int) -> Optional[IPAddressModel]:
"""根据ID获取IP"""
return db.query(IPAddressModel).filter(IPAddressModel.id == ip_id).first()
@staticmethod
def get_by_ip(db: Session, ip_address: str) -> Optional[IPAddressModel]:
"""根据IP地址获取"""
return db.query(IPAddressModel).filter(IPAddressModel.ip_address == ip_address).first()
@staticmethod
def get_list(
db: Session,
network_id: Optional[int] = None,
status: Optional[IPStatus] = None,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None
) -> tuple[int, List[IPAddressModel]]:
"""获取IP列表"""
query = db.query(IPAddressModel)
if network_id:
query = query.filter(IPAddressModel.network_id == network_id)
if status:
query = query.filter(IPAddressModel.status == status)
if search:
search_pattern = f"%{search}%"
query = query.filter(
(IPAddressModel.ip_address.like(search_pattern)) |
(IPAddressModel.mac_address.like(search_pattern)) |
(IPAddressModel.hostname.like(search_pattern)) |
(IPAddressModel.owner.like(search_pattern))
)
total = query.count()
items = query.order_by(IPAddressModel.ip_address).offset(skip).limit(limit).all()
return total, items
@staticmethod
def create(db: Session, ip_in: IPAddressCreate) -> IPAddressModel:
"""手动创建IP(通常由网段创建自动创建)"""
db_ip = IPAddressModel(**ip_in.model_dump())
db.add(db_ip)
db.commit()
db.refresh(db_ip)
return db_ip
@staticmethod
def update(db: Session, ip_id: int, ip_in: IPAddressUpdate) -> Optional[IPAddressModel]:
"""更新IP信息"""
db_ip = IPService.get_by_id(db, ip_id)
if not db_ip:
return None
update_data = ip_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_ip, field, value)
db.commit()
db.refresh(db_ip)
return db_ip
@staticmethod
def update_status(db: Session, ip_address: str, status: IPStatus,
mac_address: Optional[str] = None,
hostname: Optional[str] = None) -> Optional[IPAddressModel]:
"""更新IP状态和扫描信息"""
db_ip = IPService.get_by_ip(db, ip_address)
if not db_ip:
return None
db_ip.status = status
db_ip.last_seen = datetime.utcnow()
if mac_address:
db_ip.mac_address = mac_address
if hostname:
db_ip.hostname = hostname
db.commit()
db.refresh(db_ip)
return db_ip
@staticmethod
def set_reserved(db: Session, ip_id: int, reserved: bool = True) -> Optional[IPAddressModel]:
"""设置/取消保留IP"""
db_ip = IPService.get_by_id(db, ip_id)
if not db_ip:
return None
db_ip.status = IPStatus.RESERVED if reserved else IPStatus.AVAILABLE
db.commit()
db.refresh(db_ip)
return db_ip
@staticmethod
def bulk_update_status(db: Session, results: List[Dict[str, Any]]):
"""批量更新IP状态
Args:
results: 扫描结果列表,每个元素包含 ip_address, status, mac_address, hostname
"""
for result in results:
ip_address = result.get('ip_address')
status = result.get('status')
mac_address = result.get('mac_address')
hostname = result.get('hostname')
db_ip = IPService.get_by_ip(db, ip_address)
if db_ip:
db_ip.status = status
db_ip.last_seen = datetime.utcnow()
if mac_address:
db_ip.mac_address = mac_address
if hostname:
db_ip.hostname = hostname
db.commit()
@staticmethod
def get_by_network(db: Session, network_id: int) -> List[IPAddressModel]:
"""获取指定网段下的所有IP"""
return db.query(IPAddressModel).filter(
IPAddressModel.network_id == network_id
).order_by(IPAddressModel.ip_address).all()
+177
View File
@@ -0,0 +1,177 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func, case
import ipaddress
from app.models.network import Network as NetworkModel
from app.models.network import IPAddress as IPAddressModel
from app.models.network import IPStatus
from app.schemas.network import NetworkCreate, NetworkUpdate, NetworkStats
class NetworkService:
"""网段管理服务"""
@staticmethod
def calculate_total_ips(cidr: str) -> int:
"""计算网段的总IP数量"""
network = ipaddress.ip_network(cidr, strict=False)
return network.num_addresses
@staticmethod
def get_network_addresses(cidr: str) -> List[str]:
"""获取网段内所有IP地址列表"""
network = ipaddress.ip_network(cidr, strict=False)
return [str(ip) for ip in network.hosts()]
@staticmethod
def get_by_id(db: Session, network_id: int) -> Optional[NetworkModel]:
"""根据ID获取网段"""
return db.query(NetworkModel).filter(NetworkModel.id == network_id).first()
@staticmethod
def get_by_cidr(db: Session, cidr: str) -> Optional[NetworkModel]:
"""根据CIDR获取网段"""
return db.query(NetworkModel).filter(NetworkModel.cidr == cidr).first()
@staticmethod
def get_list(
db: Session,
skip: int = 0,
limit: int = 100,
group_name: Optional[str] = None
) -> tuple[int, List[NetworkModel]]:
"""获取网段列表"""
query = db.query(NetworkModel)
if group_name:
query = query.filter(NetworkModel.group_name == group_name)
total = query.count()
items = query.order_by(NetworkModel.id.desc()).offset(skip).limit(limit).all()
return total, items
@staticmethod
def create(db: Session, network_in: NetworkCreate) -> NetworkModel:
"""创建新网段"""
total_ips = NetworkService.calculate_total_ips(network_in.cidr)
db_network = NetworkModel(
cidr=network_in.cidr,
name=network_in.name,
description=network_in.description,
group_name=network_in.group_name,
gateway=network_in.gateway,
vlan_id=network_in.vlan_id,
total_ips=total_ips,
used_ips=0,
reserved_ips=0
)
db.add(db_network)
db.flush()
# 自动创建该网段下的所有IP记录
NetworkService._create_ip_addresses(db, db_network)
db.commit()
db.refresh(db_network)
return db_network
@staticmethod
def _create_ip_addresses(db: Session, network: NetworkModel):
"""为网段创建所有IP记录"""
ip_addresses = NetworkService.get_network_addresses(network.cidr)
ip_records = []
for ip in ip_addresses:
# 标记网关为保留状态
status = IPStatus.RESERVED if ip == network.gateway else IPStatus.AVAILABLE
ip_records.append(
IPAddressModel(
network_id=network.id,
ip_address=ip,
status=status
)
)
db.bulk_save_objects(ip_records)
# 更新统计
reserved_count = sum(1 for ip in ip_addresses if ip == network.gateway)
network.reserved_ips = reserved_count
@staticmethod
def update(db: Session, network_id: int, network_in: NetworkUpdate) -> Optional[NetworkModel]:
"""更新网段信息"""
db_network = NetworkService.get_by_id(db, network_id)
if not db_network:
return None
update_data = network_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_network, field, value)
db.commit()
db.refresh(db_network)
return db_network
@staticmethod
def delete(db: Session, network_id: int) -> bool:
"""删除网段"""
db_network = NetworkService.get_by_id(db, network_id)
if not db_network:
return False
db.delete(db_network)
db.commit()
return True
@staticmethod
def get_stats(db: Session, network_id: int) -> Optional[NetworkStats]:
"""获取网段统计信息"""
db_network = NetworkService.get_by_id(db, network_id)
if not db_network:
return None
# 实时计算IP状态 - 使用SUM(CASE)兼容MySQL
stats = db.query(
func.sum(case((IPAddressModel.status == IPStatus.ONLINE, 1), else_=0)).label('online'),
func.sum(case((IPAddressModel.status == IPStatus.OFFLINE, 1), else_=0)).label('offline'),
func.sum(case((IPAddressModel.status == IPStatus.RESERVED, 1), else_=0)).label('reserved'),
).filter(IPAddressModel.network_id == network_id).first()
used_ips = int(stats.online or 0) + int(stats.offline or 0)
reserved_ips = int(stats.reserved or 0)
available_ips = db_network.total_ips - used_ips - reserved_ips
usage_percent = (used_ips / db_network.total_ips * 100) if db_network.total_ips > 0 else 0
# 更新数据库统计
db_network.used_ips = used_ips
db_network.reserved_ips = reserved_ips
db.commit()
return NetworkStats(
id=db_network.id,
cidr=db_network.cidr,
name=db_network.name,
total_ips=db_network.total_ips,
used_ips=used_ips,
reserved_ips=reserved_ips,
available_ips=available_ips,
usage_percent=round(usage_percent, 2),
group_name=db_network.group_name,
vlan_id=db_network.vlan_id
)
@staticmethod
def get_all_stats(db: Session) -> List[NetworkStats]:
"""获取所有网段的统计信息"""
networks = db.query(NetworkModel).all()
stats_list = []
for network in networks:
stats = NetworkService.get_stats(db, network.id)
if stats:
stats_list.append(stats)
return stats_list
+148
View File
@@ -0,0 +1,148 @@
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from datetime import datetime
import asyncio
import subprocess
import ipaddress
from app.models.network import ScanTask, Network, IPAddress
from app.models.network import TaskStatus, TaskType, IPStatus
from app.core.config import settings
class ScanService:
"""扫描服务"""
@staticmethod
def ping_single_ip(ip_address: str, timeout: int = 2) -> Dict[str, Any]:
"""
对单个IP执行Ping扫描
"""
try:
result = subprocess.run(
['ping', '-c', '1', '-W', str(timeout), ip_address],
capture_output=True,
text=True
)
is_alive = result.returncode == 0
return {
'ip_address': ip_address,
'status': 'online' if is_alive else 'offline',
'response_time': None,
'success': is_alive
}
except Exception as e:
return {
'ip_address': ip_address,
'status': 'error',
'error': str(e),
'success': False
}
@staticmethod
def ping_network(cidr: str) -> List[Dict[str, Any]]:
"""
对整个网段执行Ping扫描(同步版本)
"""
network = ipaddress.ip_network(cidr, strict=False)
ips = [str(ip) for ip in network.hosts()]
results = []
for ip in ips:
result = ScanService.ping_single_ip(ip, settings.PING_TIMEOUT)
results.append(result)
return results
@staticmethod
def create_scan_task(db: Session, task_type: TaskType,
network_id: Optional[int] = None) -> ScanTask:
"""
创建扫描任务
"""
task = ScanTask(
task_type=task_type,
network_id=network_id,
status=TaskStatus.PENDING,
progress=0
)
db.add(task)
db.commit()
db.refresh(task)
return task
@staticmethod
def update_task_status(db: Session, task_id: int, status: TaskStatus,
progress: int = None, total_count: int = None,
success_count: int = None, failed_count: int = None,
error_message: str = None):
"""
更新任务状态
"""
task = db.query(ScanTask).filter(ScanTask.id == task_id).first()
if not task:
return
task.status = status
if progress is not None:
task.progress = progress
if total_count is not None:
task.total_count = total_count
if success_count is not None:
task.success_count = success_count
if failed_count is not None:
task.failed_count = failed_count
if error_message:
task.error_message = error_message
if status == TaskStatus.RUNNING and task.started_at is None:
task.started_at = datetime.utcnow()
elif status in [TaskStatus.COMPLETED, TaskStatus.FAILED]:
task.completed_at = datetime.utcnow()
db.commit()
@staticmethod
def get_task_by_id(db: Session, task_id: int) -> Optional[ScanTask]:
"""
获取任务信息
"""
return db.query(ScanTask).filter(ScanTask.id == task_id).first()
@staticmethod
def get_task_list(db: Session, skip: int = 0, limit: int = 50) -> tuple[int, List[ScanTask]]:
"""
获取任务列表
"""
query = db.query(ScanTask)
total = query.count()
items = query.order_by(ScanTask.id.desc()).offset(skip).limit(limit).all()
return total, items
@staticmethod
def update_ip_status_from_scan_results(db: Session, results: List[Dict[str, Any]]):
"""
根据扫描结果更新IP状态
"""
online_count = 0
offline_count = 0
for result in results:
ip_address = result.get('ip_address')
is_online = result.get('success', False)
db_ip = db.query(IPAddress).filter(IPAddress.ip_address == ip_address).first()
if db_ip:
if is_online:
db_ip.status = IPStatus.ONLINE
db_ip.last_seen = datetime.utcnow()
online_count += 1
elif db_ip.status == IPStatus.ONLINE:
# 曾经在线,现在不在线,标记为离线
db_ip.status = IPStatus.OFFLINE
offline_count += 1
db.commit()
return online_count, offline_count
+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
+420
View File
@@ -0,0 +1,420 @@
from typing import Optional, List
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from fastapi import HTTPException, status
from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken
from app.core.security import SecurityUtils
# 默认权限项 + 角色权限映射
DEFAULT_PERMISSIONS = [
# 网段
("network:view", "查看网段", "network"),
("network:create", "创建网段", "network"),
("network:edit", "编辑网段", "network"),
("network:delete", "删除网段", "network"),
# IP
("ip:view", "查看 IP", "ip"),
("ip:edit", "编辑 IP", "ip"),
# 扫描
("scan:run", "执行扫描", "scan"),
("scan:view", "查看扫描结果", "scan"),
# SNMP
("snmp:view", "查看 SNMP", "snmp"),
("snmp:edit", "配置 SNMP", "snmp"),
# 告警
("alert:view", "查看告警", "alert"),
("alert:ack", "确认告警", "alert"),
("alert:resolve", "解决告警", "alert"),
# 报告
("report:export", "导出报告", "report"),
# 用户管理
("user:view", "查看用户", "system"),
("user:create", "创建用户", "system"),
("user:edit", "编辑用户", "system"),
("user:delete", "删除用户", "system"),
# 审计
("audit:view", "查看审计日志", "system"),
]
# 角色 -> 权限代码集合
DEFAULT_ROLE_PERMISSIONS = {
UserRole.SUPER_ADMIN: {p[0] for p in DEFAULT_PERMISSIONS}, # 全部
UserRole.ADMIN: {
"network:view", "network:create", "network:edit", "network:delete",
"ip:view", "ip:edit",
"scan:run", "scan:view",
"snmp:view", "snmp:edit",
"alert:view", "alert:ack", "alert:resolve",
"report:export",
"user:view", "audit:view",
},
UserRole.OPERATOR: {
"network:view",
"ip:view", "ip:edit",
"scan:run", "scan:view",
"snmp:view",
"alert:view", "alert:ack",
"report:export",
},
UserRole.VIEWER: {
"network:view",
"ip:view",
"scan:view",
"snmp:view",
"alert:view",
},
}
class UserService:
"""用户服务"""
@staticmethod
def init_default_permissions(db: Session) -> int:
"""初始化默认权限项和角色权限映射(已存在则跳过)"""
created = 0
perm_map = {}
for code, name, category in DEFAULT_PERMISSIONS:
existing = db.query(Permission).filter(Permission.code == code).first()
if existing:
perm_map[code] = existing
continue
p = Permission(code=code, name=name, category=category)
db.add(p)
db.flush()
perm_map[code] = p
created += 1
# 角色 -> 权限关联
for role, codes in DEFAULT_ROLE_PERMISSIONS.items():
for code in codes:
if code not in perm_map:
continue
pid = perm_map[code].id
link_existing = db.query(RolePermission).filter(
RolePermission.role == role,
RolePermission.permission_id == pid
).first()
if link_existing:
continue
db.add(RolePermission(role=role, permission_id=pid))
db.commit()
return created
@staticmethod
def get_user_permissions(db: Session, user: User) -> set:
"""获取用户拥有的全部权限代码集合"""
if user.role == UserRole.SUPER_ADMIN:
return {p.code for p in db.query(Permission).all()}
# 其他角色查 role_permissions 关联表
rows = db.query(Permission.code).join(
RolePermission, RolePermission.permission_id == Permission.id
).filter(RolePermission.role == user.role).all()
return {r[0] for r in rows}
@staticmethod
def init_default_admin(db: Session):
"""初始化默认管理员账户"""
admin_exists = db.query(User).filter(User.username == "admin").first()
if not admin_exists:
admin = User(
username="admin",
email="admin@ipam.local",
real_name="系统管理员",
password_hash=SecurityUtils.hash_password("admin123"),
role=UserRole.SUPER_ADMIN,
status=UserStatus.ACTIVE
)
db.add(admin)
db.commit()
db.refresh(admin)
return admin
return None
@staticmethod
def authenticate(db: Session, username: str, password: str) -> Optional[User]:
"""用户认证"""
user = db.query(User).filter(User.username == username).first()
if not user:
return None
if not SecurityUtils.verify_password(password, user.password_hash):
# 记录登录失败
user.login_failed_count += 1
# 登录失败5次锁定账号
if user.login_failed_count >= 5:
user.status = UserStatus.LOCKED
db.commit()
return None
# 登录成功,重置失败计数
if user.login_failed_count > 0:
user.login_failed_count = 0
db.commit()
# 检查用户状态
if user.status != UserStatus.ACTIVE:
return None
return user
@staticmethod
def create_user(db: Session,
username: str,
password: str,
email: Optional[str] = None,
phone: Optional[str] = None,
real_name: Optional[str] = None,
role: UserRole = UserRole.VIEWER,
created_by: Optional[int] = None) -> User:
"""创建用户"""
# 检查用户名是否存在
existing = db.query(User).filter(User.username == username).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="用户名已存在"
)
# 检查邮箱是否存在
if email:
existing_email = db.query(User).filter(User.email == email).first()
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="邮箱已被使用"
)
user = User(
username=username,
email=email,
phone=phone,
real_name=real_name,
password_hash=SecurityUtils.hash_password(password),
role=role,
status=UserStatus.ACTIVE,
created_by=created_by
)
db.add(user)
db.commit()
db.refresh(user)
return user
@staticmethod
def update_login_info(db: Session, user_id: int, ip_address: str = None):
"""更新登录信息"""
user = db.query(User).filter(User.id == user_id).first()
if user:
user.last_login_at = datetime.utcnow()
user.last_login_ip = ip_address
db.commit()
@staticmethod
def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
"""根据ID获取用户"""
return db.query(User).filter(User.id == user_id).first()
@staticmethod
def get_user_by_username(db: Session, username: str) -> Optional[User]:
"""根据用户名获取用户"""
return db.query(User).filter(User.username == username).first()
@staticmethod
def get_users(db: Session, skip: int = 0, limit: int = 50, status: UserStatus = None, role: UserRole = None) -> tuple[int, List[User]]:
"""获取用户列表"""
query = db.query(User)
if status:
query = query.filter(User.status == status)
if role:
query = query.filter(User.role == role)
total = query.count()
items = query.order_by(User.id.desc()).offset(skip).limit(limit).all()
return total, items
@staticmethod
def change_password(db: Session, user_id: int, old_password: str, new_password: str) -> bool:
"""修改密码"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return False
if not SecurityUtils.verify_password(old_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="原密码不正确"
)
user.password_hash = SecurityUtils.hash_password(new_password)
db.commit()
return True
@staticmethod
def reset_password(db: Session, user_id: int, new_password: str) -> bool:
"""管理员重置密码"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return False
user.password_hash = SecurityUtils.hash_password(new_password)
db.commit()
return True
@staticmethod
def update_user_status(db: Session, user_id: int, status: UserStatus) -> Optional[User]:
"""更新用户状态"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return None
user.status = status
db.commit()
db.refresh(user)
return user
@staticmethod
def delete_user(db: Session, user_id: int) -> bool:
"""删除用户(软删除,实际标记为禁用)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return False
user.status = UserStatus.INACTIVE
db.commit()
return True
@staticmethod
def unlock_user(db: Session, user_id: int) -> Optional[User]:
"""解锁被锁定的用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return None
if user.status == UserStatus.LOCKED:
user.status = UserStatus.ACTIVE
user.login_failed_count = 0
db.commit()
db.refresh(user)
return user
class TokenService:
"""令牌服务"""
@staticmethod
def create_tokens(db: Session, user: User, ip_address: str = None, user_agent: str = None) -> dict:
"""创建访问令牌和刷新令牌"""
access_token_expires = timedelta(minutes=120) # 访问令牌2小时
access_token = SecurityUtils.create_access_token(
data={
"sub": user.username,
"user_id": user.id,
"role": user.role.value
},
expires_delta=access_token_expires
)
refresh_token_expires = timedelta(days=7) # 刷新令牌7天
refresh_token_value = SecurityUtils.create_refresh_token(
data={
"sub": user.username,
"user_id": user.id
},
expires_delta=refresh_token_expires
)
# 保存刷新令牌
refresh_token = RefreshToken(
user_id=user.id,
token=refresh_token_value,
expires_at=datetime.utcnow() + refresh_token_expires,
ip_address=ip_address,
user_agent=user_agent
)
db.add(refresh_token)
db.commit()
return {
"access_token": access_token,
"refresh_token": refresh_token_value,
"token_type": "bearer",
"expires_in": int(access_token_expires.total_seconds())
}
@staticmethod
def refresh_access_token(db: Session, refresh_token: str) -> Optional[dict]:
"""使用刷新令牌获取新的访问令牌"""
try:
payload = SecurityUtils.verify_token(refresh_token)
username = payload.get("sub")
user_id = payload.get("user_id")
# 检查刷新令牌是否有效且未被撤销
token_record = db.query(RefreshToken).filter(
RefreshToken.token == refresh_token,
RefreshToken.user_id == user_id,
RefreshToken.revoked == False,
RefreshToken.expires_at > datetime.utcnow()
).first()
if not token_record:
return None
user = db.query(User).filter(User.id == user_id).first()
if not user or user.status != UserStatus.ACTIVE:
return None
# 创建新的访问令牌
access_token_expires = timedelta(minutes=120)
access_token = SecurityUtils.create_access_token(
data={
"sub": user.username,
"user_id": user.id,
"role": user.role.value
},
expires_delta=access_token_expires
)
return {
"access_token": access_token,
"token_type": "bearer",
"expires_in": int(access_token_expires.total_seconds())
}
except Exception:
return None
@staticmethod
def revoke_token(db: Session, refresh_token: str) -> bool:
"""撤销刷新令牌"""
token_record = db.query(RefreshToken).filter(RefreshToken.token == refresh_token).first()
if token_record:
token_record.revoked = True
token_record.revoked_at = datetime.utcnow()
db.commit()
return True
return False
@staticmethod
def revoke_all_user_tokens(db: Session, user_id: int) -> int:
"""撤销用户所有刷新令牌"""
tokens = db.query(RefreshToken).filter(
RefreshToken.user_id == user_id,
RefreshToken.revoked == False
).all()
for token in tokens:
token.revoked = True
token.revoked_at = datetime.utcnow()
db.commit()
return len(tokens)