Files
ipam/backend/app/services/alert_service.py
T
Your Name 717fa31456 feat(alerts): 告警中心增加飞书机器人告警推送功能
1. 新增 feishu_service.py: 飞书自定义机器人 webhook 推送
   - 支持卡片消息(interactive),按告警级别着色
   - 支持签名校验(HMAC-SHA256)
   - 支持最低级别过滤 + 告警类型白名单
2. alert_service.create_alert 创建新告警时自动推飞书
   - 仅新告警推送,去重告警不刷屏
   - 推送失败不影响告警创建
3. alerts.py 新增两个接口:
   - GET /alerts/notify/feishu/status 查看配置状态
   - POST /alerts/notify/feishu/test 发送测试告警
4. config.py + .env.example 增加 FEISHU_* 配置项
   (WEBHOOK_URL / SECRET / ENABLED / MIN_SEVERITY / ALERT_TYPES)
2026-08-12 13:10:08 +08:00

526 lines
18 KiB
Python

from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
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
# 飞书告警通知(延迟导入避免循环依赖)
_feishu_service = None
def _get_feishu_service():
global _feishu_service
if _feishu_service is None:
from app.services.feishu_service import get_feishu_service
_feishu_service = get_feishu_service()
return _feishu_service
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}")
# 飞书告警通知(仅新创建的告警推送;existing 去重返回的不会走到这里,避免刷屏)
try:
_get_feishu_service().send_alert({
"alert_type": alert_type.value if hasattr(alert_type, "value") else str(alert_type),
"severity": severity.value if hasattr(severity, "value") else str(severity),
"title": title,
"message": message,
"ip_address_str": ip_address_str,
"mac_address": mac_address,
"conflicting_mac": conflicting_mac,
"usage_percent": usage_percent,
})
except Exception as e:
logger.error(f"飞书告警通知失败(不影响告警创建): {e}")
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(
func.count(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