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
+19
View File
@@ -0,0 +1,19 @@
# 数据库配置
MYSQL_ROOT_PASSWORD=ipam2024
MYSQL_DATABASE=ipam
MYSQL_USER=ipam
MYSQL_PASSWORD=ipam2024
# 应用配置
DATABASE_URL=mysql+pymysql://ipam:***@localhost:3306/ipam
REDIS_URL=redis://localhost:6379/0
CELERY_BROKER_URL=redis://localhost:6379/1
CELERY_RESULT_BACKEND=redis://localhost:6379/2
# JWT配置
SECRET_KEY=your-super-secret-key-here-change-in-production
# 扫描配置
PING_TIMEOUT=2
PING_RETRIES=2
SCAN_CONCURRENCY=50
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.11-slim
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y \
gcc \
libffi-dev \
libssl-dev \
iputils-ping \
net-tools \
&& rm -rf /var/lib/apt/lists/*
# 安装Python依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码
COPY . .
# 创建启动脚本
RUN echo '#!/bin/bash\n\
echo "Waiting for MySQL..."\n\
sleep 10\n\
echo "Starting FastAPI server..."\n\
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload' > /app/start.sh && chmod +x /app/start.sh
CMD ["/app/start.sh"]
+1
View File
@@ -0,0 +1 @@
# app/__init__.py
Binary file not shown.
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter
from app.api.v1 import networks, ips, scan, enhanced_scan, async_scan, snmp, alerts, auth, audit
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(networks.router)
api_router.include_router(ips.router)
api_router.include_router(scan.router)
api_router.include_router(enhanced_scan.router)
api_router.include_router(async_scan.router)
api_router.include_router(snmp.router)
api_router.include_router(alerts.router)
api_router.include_router(auth.router)
api_router.include_router(audit.router)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+182
View File
@@ -0,0 +1,182 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.services.alert_service import AlertService
router = APIRouter(
prefix="/alerts",
tags=["告警管理"],
dependencies=[Depends(get_current_user)],
)
@router.get("", summary="获取告警列表")
def get_alerts(
status: Optional[str] = None,
severity: Optional[str] = None,
alert_type: Optional[str] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""获取告警列表"""
from app.models.alert import 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": total, "items": items}
@router.get("/{alert_id}", summary="获取告警详情")
def get_alert(alert_id: int, db: Session = Depends(get_db)):
from app.models.alert import Alert
alert = db.query(Alert).filter(Alert.id == alert_id).first()
if not alert:
raise HTTPException(status_code=404, detail="告警不存在")
return alert
@router.post("/{alert_id}/acknowledge", summary="确认告警")
def acknowledge_alert(alert_id: int, acknowledged_by: str = "system", db: Session = Depends(get_db)):
"""确认告警"""
alert = AlertService.acknowledge_alert(db, alert_id, acknowledged_by)
if not alert:
raise HTTPException(status_code=404, detail="告警不存在")
return {"message": "告警已确认", "alert": alert}
@router.post("/{alert_id}/resolve", summary="解决告警")
def resolve_alert(alert_id: int, resolved_by: str = "system", notes: str = "", db: Session = Depends(get_db)):
"""标记告警为已解决"""
alert = AlertService.resolve_alert(db, alert_id, resolved_by, notes)
if not alert:
raise HTTPException(status_code=404, detail="告警不存在")
return {"message": "告警已解决", "alert": alert}
@router.post("/{alert_id}/ignore", summary="忽略告警")
def ignore_alert(alert_id: int, db: Session = Depends(get_db)):
"""忽略告警"""
alert = AlertService.ignore_alert(db, alert_id)
if not alert:
raise HTTPException(status_code=404, detail="告警不存在")
return {"message": "告警已忽略", "alert": alert}
@router.post("/detect/run", summary="运行所有检测")
def run_detection(db: Session = Depends(get_db)):
"""立即运行所有告警检测"""
results = AlertService.run_all_detections(db)
return {"message": "检测完成", "results": results}
@router.get("/statistics/summary", summary="告警统计摘要")
def get_alert_statistics(db: Session = Depends(get_db)):
"""获取告警统计信息"""
stats = AlertService.get_statistics(db)
return stats
# ========== MAC 白名单管理 ==========
@router.get("/whitelist/macs", summary="获取 MAC 白名单")
def get_mac_whitelist(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""获取 MAC 地址白名单"""
total, items = AlertService.get_mac_whitelist(db, skip=skip, limit=limit)
return {"total": total, "items": items}
@router.post("/whitelist/macs", summary="添加 MAC 到白名单")
def add_mac_to_whitelist(
mac_address: str,
description: Optional[str] = None,
owner: Optional[str] = None,
db: Session = Depends(get_db)
):
"""添加 MAC 地址到白名单"""
whitelist_mac = AlertService.add_mac_to_whitelist(
db,
mac_address=mac_address,
description=description,
owner=owner
)
return {"message": "MAC 已添加到白名单", "item": whitelist_mac}
@router.delete("/whitelist/macs/{mac_id}", summary="从白名单移除 MAC")
def remove_mac_from_whitelist(mac_id: int, db: Session = Depends(get_db)):
"""从白名单移除 MAC 地址"""
success = AlertService.remove_mac_from_whitelist(db, mac_id)
if not success:
raise HTTPException(status_code=404, detail="MAC 不存在")
return {"message": "MAC 已从白名单移除"}
# ========== 单独检测接口 ==========
@router.post("/detect/ip-conflicts", summary="检测 IP 冲突")
def detect_ip_conflicts(db: Session = Depends(get_db)):
"""仅检测 IP 冲突"""
conflicts = AlertService.detect_ip_conflicts(db)
return {
"count": len(conflicts),
"conflicts": conflicts
}
@router.post("/detect/unauthorized", summary="检测未授权接入")
def detect_unauthorized_access(db: Session = Depends(get_db)):
"""仅检测未授权接入"""
unauthorized = AlertService.detect_unauthorized_access(db)
return {
"count": len(unauthorized),
"devices": unauthorized
}
@router.post("/detect/subnet-exhaustion", summary="检测网段耗尽")
def detect_subnet_exhaustion(db: Session = Depends(get_db)):
"""仅检测网段耗尽"""
exhaustion = AlertService.detect_subnet_exhaustion(db)
return {
"count": len(exhaustion),
"subnets": exhaustion
}
@router.post("/detect/new-devices", summary="检测新设备")
def detect_new_devices(db: Session = Depends(get_db)):
"""仅检测新发现的设备"""
new_devices = AlertService.detect_new_devices(db)
return {
"count": len(new_devices),
"devices": new_devices
}
@router.post("/detect/offline-devices", summary="检测离线设备")
def detect_offline_devices(db: Session = Depends(get_db)):
"""仅检测离线设备"""
offline_devices = AlertService.detect_device_offline(db)
return {
"count": len(offline_devices),
"devices": offline_devices
}
+211
View File
@@ -0,0 +1,211 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Dict, Any, Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.schemas.network import ScanTask
from app.models.network import TaskStatus
from app.services.network_service import NetworkService
from app.services.scan_service import ScanService
router = APIRouter(
prefix="/async-scan",
tags=["异步扫描"],
dependencies=[Depends(get_current_user)],
)
@router.post("/network/{network_id}", response_model=ScanTask, summary="异步扫描网段")
def async_scan_network(
network_id: int,
enable_ping: bool = True,
enable_arp: bool = True,
enable_dns: bool = True,
timeout: int = 2,
db: Session = Depends(get_db)
):
"""
提交异步网段扫描任务,立即返回任务ID,后台执行
"""
network = NetworkService.get_by_id(db, network_id)
if not network:
raise HTTPException(status_code=404, detail="网段不存在")
# 创建数据库任务记录
db_task = ScanService.create_scan_task(db, 'ping', network_id)
# 提交 Celery 任务
from app.tasks.celery_app import scan_network_task
celery_task = scan_network_task.delay(
network_id=network_id,
enable_ping=enable_ping,
enable_arp=enable_arp,
enable_dns=enable_dns,
timeout=timeout
)
# 记录 Celery 任务ID
db_task.celery_task_id = celery_task.id
db.commit()
db.refresh(db_task)
return db_task
@router.post("/ip/{ip_address}", summary="异步扫描单个IP")
def async_scan_ip(
ip_address: str,
enable_ping: bool = True,
enable_arp: bool = True,
enable_dns: bool = True
):
"""
提交异步单个IP扫描任务
"""
from app.tasks.celery_app import scan_single_ip_task
celery_task = scan_single_ip_task.delay(
ip_address=ip_address,
enable_ping=enable_ping,
enable_arp=enable_arp,
enable_dns=enable_dns
)
return {
"task_id": celery_task.id,
"ip_address": ip_address,
"status": "pending"
}
@router.post("/quick", summary="快速批量IP状态更新")
def quick_scan_ips(ip_addresses: List[str]):
"""
快速批量更新IP状态(只做Ping+ARP,不做DNS
"""
from app.tasks.celery_app import quick_status_update
celery_task = quick_status_update.delay(ip_addresses=ip_addresses)
return {
"task_id": celery_task.id,
"ips_count": len(ip_addresses),
"status": "pending"
}
@router.get("/task/{task_id}", summary="查询异步任务状态")
def get_async_task_status(task_id: str, db: Session = Depends(get_db)):
"""
根据 Celery 任务ID 查询任务状态
"""
# 先查询数据库任务
from app.models.network import ScanTask as DbScanTask
db_task = db.query(DbScanTask).filter(DbScanTask.celery_task_id == task_id).first()
# 直接查询 Celery
from celery.result import AsyncResult
result = AsyncResult(task_id)
response = {
"task_id": task_id,
"celery_status": result.status
}
if db_task:
response["db_status"] = db_task.status.value
response["progress"] = db_task.progress
response["total_count"] = db_task.total_count
response["success_count"] = db_task.success_count
response["started_at"] = db_task.started_at
response["completed_at"] = db_task.completed_at
if result.status == 'SUCCESS':
response["result"] = result.result
elif result.status == 'FAILURE':
response["error"] = str(result.info)
return response
@router.get("/tasks/running", summary="获取运行中的任务列表")
def get_running_tasks(db: Session = Depends(get_db)):
"""
获取所有正在运行的扫描任务
"""
from app.models.network import ScanTask as DbScanTask
running = db.query(DbScanTask).filter(DbScanTask.status == TaskStatus.RUNNING).all()
pending = db.query(DbScanTask).filter(DbScanTask.status == TaskStatus.PENDING).all()
return {
"running_count": len(running),
"pending_count": len(pending),
"running_tasks": running,
"pending_tasks": pending
}
@router.post("/task/{task_id}/cancel", summary="取消异步任务")
def cancel_async_task(task_id: str):
"""
取消正在运行的异步任务
"""
from celery.result import AsyncResult
result = AsyncResult(task_id)
if result.status in ['PENDING', 'RUNNING']:
result.revoke(terminate=True)
return {"status": "cancelled", "task_id": task_id}
else:
return {"status": "not_running", "current_status": result.status}
@router.post("/full-scan", summary="触发全量扫描")
def trigger_full_scan(
enable_ping: bool = True,
enable_arp: bool = True,
enable_dns: bool = True
):
"""
触发全量网段扫描(所有网段)
"""
from app.tasks.celery_app import full_network_scan
celery_task = full_network_scan.delay(
enable_ping=enable_ping,
enable_arp=enable_arp,
enable_dns=enable_dns
)
return {
"task_id": celery_task.id,
"status": "pending",
"message": "全量扫描任务已提交"
}
@router.get("/celery/inspect", summary="Celery Worker 状态检查")
def inspect_celery():
"""
检查 Celery Worker 状态
"""
from app.tasks.celery_app import celery_app
try:
inspector = celery_app.control.inspect()
active = inspector.active()
scheduled = inspector.scheduled()
reserved = inspector.reserved()
stats = inspector.stats()
return {
"status": "healthy" if stats else "no_workers",
"active_tasks": active,
"scheduled_tasks": scheduled,
"reserved_tasks": reserved,
"worker_stats": stats
}
except Exception as e:
return {
"status": "error",
"error": str(e),
"message": "Celery Worker 可能未启动"
}
+164
View File
@@ -0,0 +1,164 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
import csv
import io
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.auth import User
from app.services.audit_service import AuditService
router = APIRouter(
prefix="/audit",
tags=["审计日志"],
dependencies=[Depends(get_current_user)],
)
@router.get("/logs", summary="查询审计日志")
def get_audit_logs(
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 = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
# 当前用户必须是 admin 或 super_admin 才能看审计
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
# 角色检查:super_admin / admin 可以看所有人的,operator/viewer 只能看自己的
if current_user.role not in ("super_admin", "admin"):
user_id = current_user.id
total, items = AuditService.get_logs(
db,
user_id=user_id,
username=username,
action=action,
resource_type=resource_type,
status=status,
start_time=start_time,
end_time=end_time,
skip=skip,
limit=limit,
)
serialized = []
for log in items:
serialized.append({
"id": log.id,
"user_id": log.user_id,
"username": log.username,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"resource_name": log.resource_name,
"method": log.method,
"path": log.path,
"ip_address": log.ip_address,
"user_agent": log.user_agent,
"status": log.status,
"detail": log.detail,
"created_at": log.created_at.isoformat() if log.created_at else None,
})
return {"total": total, "items": serialized}
@router.get("/logs/export", summary="导出审计日志为 CSV")
def export_audit_logs(
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,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""导出 CSV 流。admin/super_admin 看全部;operator/viewer 仅能导出自己的。"""
if current_user.role not in ("super_admin", "admin"):
user_id = current_user.id
# 导出上限 50000 行,避免一次拉太多 OOM
total, items = AuditService.get_logs(
db,
user_id=user_id,
username=username,
action=action,
resource_type=resource_type,
status=status,
start_time=start_time,
end_time=end_time,
skip=0,
limit=50000,
)
output = io.StringIO()
# 写入 UTF-8 BOM 让 Excel 直接打开不乱码
output.write("\ufeff")
writer = csv.writer(output)
writer.writerow([
"ID", "时间", "用户", "操作", "资源类型", "资源ID", "资源名称",
"HTTP方法", "路径", "客户端IP", "状态", "详情"
])
for log in items:
writer.writerow([
log.id,
log.created_at.isoformat() if log.created_at else "",
log.username or "",
log.action or "",
log.resource_type or "",
log.resource_id or "",
log.resource_name or "",
log.method or "",
log.path or "",
log.ip_address or "",
log.status or "",
(log.detail or "").replace("\n", " ")[:500],
])
output.seek(0)
filename = f"audit_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/stats", summary="审计日志统计")
def get_audit_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
if current_user.role not in ("super_admin", "admin"):
# operator/viewer 只能看自己的简单计数
from app.models.audit import AuditLog
from sqlalchemy import func
my_count = db.query(func.count(AuditLog.id)).filter(AuditLog.user_id == current_user.id).scalar()
return {"recent_24h": 0, "by_action_24h": {}, "my_total": int(my_count or 0)}
return AuditService.get_stats(db)
@router.get("/actions", summary="获取所有审计操作类型(用于前端过滤下拉框)")
def get_action_types():
"""硬编码返回常用 action 列表,避免每次都查 DB"""
from app.services.audit_service import AuditAction
actions = []
for name in dir(AuditAction):
if name.startswith("_") or name.isupper() and not name.startswith("__"):
continue
val = getattr(AuditAction, name, None)
if isinstance(val, str):
actions.append({"code": val, "name": name})
return actions
+359
View File
@@ -0,0 +1,359 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user, require_permission
from app.models.auth import User, UserRole, UserStatus
from app.services.user_service import UserService, TokenService
from app.services.audit_service import AuditService, AuditAction
router = APIRouter(prefix="/auth", tags=["认证管理"])
@router.post("/login", summary="用户登录")
def login(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""用户登录,获取访问令牌"""
user = UserService.authenticate(db, form_data.username, form_data.password)
if not user:
# 记录失败尝试
AuditService.record(
db,
action=AuditAction.USER_LOGIN_FAILED,
username=form_data.username,
method="POST",
path=str(request.url.path),
ip_address=request.client.host if request.client else "",
user_agent=(request.headers.get("user-agent") or "")[:500],
status="failed",
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
# 更新登录信息
UserService.update_login_info(db, user.id, ip_address=request.client.host if request.client else None)
# 创建令牌
tokens = TokenService.create_tokens(
db, user,
ip_address=request.client.host if request.client else None,
user_agent=(request.headers.get("user-agent") or "")[:500],
)
AuditService.record(
db,
action=AuditAction.USER_LOGIN,
user=user,
method="POST",
path=str(request.url.path),
ip_address=request.client.host if request.client else "",
user_agent=(request.headers.get("user-agent") or "")[:500],
)
return {
**tokens,
"user_info": {
"id": user.id,
"username": user.username,
"email": user.email,
"real_name": user.real_name,
"role": user.role.value
}
}
@router.post("/refresh", summary="刷新访问令牌")
def refresh_token(refresh_token: str, db: Session = Depends(get_db)):
"""使用刷新令牌获取新的访问令牌"""
result = TokenService.refresh_access_token(db, refresh_token)
if not result:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的刷新令牌或已过期",
headers={"WWW-Authenticate": "Bearer"},
)
return result
@router.post("/logout", summary="登出")
def logout(
request: Request,
refresh_token: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""用户登出,撤销刷新令牌"""
TokenService.revoke_token(db, refresh_token)
AuditService.record(
db,
action=AuditAction.USER_LOGOUT,
user=current_user,
method="POST",
path=str(request.url.path),
ip_address=request.client.host if request.client else "",
user_agent=(request.headers.get("user-agent") or "")[:500],
)
return {"message": "登出成功"}
@router.get("/me", summary="获取当前用户信息")
def get_current_user_info(current_user: User = Depends(get_current_user)):
"""获取当前登录用户信息"""
return {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"phone": current_user.phone,
"real_name": current_user.real_name,
"role": current_user.role.value,
"status": current_user.status.value,
"last_login_at": current_user.last_login_at,
"email_notification": current_user.email_notification,
"wechat_notification": current_user.wechat_notification,
"dingtalk_notification": current_user.dingtalk_notification
}
@router.post("/change-password", summary="修改密码")
def change_password(
old_password: str,
new_password: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""用户修改自己的密码"""
if len(new_password) < 6:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="新密码长度不能少于6位"
)
try:
UserService.change_password(db, current_user.id, old_password, new_password)
return {"message": "密码修改成功,请重新登录"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"密码修改失败: {str(e)}"
)
# ========== 用户管理 API (需要管理员权限) ==========
@router.get("/users", summary="获取用户列表")
def get_users(
status: Optional[UserStatus] = None,
role: Optional[UserRole] = None,
skip: int = 0,
limit: int = 50,
current_user: User = Depends(require_permission("user:view")),
db: Session = Depends(get_db)
):
"""获取用户列表(需要管理员权限)"""
total, items = UserService.get_users(db, skip=skip, limit=limit, status=status, role=role)
# 返回脱敏的用户信息
user_list = []
for user in items:
user_list.append({
"id": user.id,
"username": user.username,
"email": user.email,
"phone": user.phone,
"real_name": user.real_name,
"role": user.role.value,
"status": user.status.value,
"last_login_at": user.last_login_at,
"created_at": user.created_at
})
return {"total": total, "items": user_list}
@router.get("/users/{user_id}", summary="获取用户详情")
def get_user(
user_id: int,
current_user: User = Depends(require_permission("user:view")),
db: Session = Depends(get_db)
):
"""获取用户详情(需要管理员权限)"""
user = UserService.get_user_by_id(db, user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return {
"id": user.id,
"username": user.username,
"email": user.email,
"phone": user.phone,
"real_name": user.real_name,
"role": user.role.value,
"status": user.status.value,
"last_login_at": user.last_login_at,
"last_login_ip": user.last_login_ip,
"login_failed_count": user.login_failed_count,
"created_at": user.created_at,
"email_notification": user.email_notification,
"wechat_notification": user.wechat_notification,
"dingtalk_notification": user.dingtalk_notification
}
@router.post("/users", summary="创建用户", status_code=status.HTTP_201_CREATED)
def create_user(
username: str,
password: str,
email: Optional[str] = None,
phone: Optional[str] = None,
real_name: Optional[str] = None,
role: UserRole = UserRole.VIEWER,
current_user: User = Depends(require_permission("user:create")),
db: Session = Depends(get_db)
):
"""创建新用户(需要超级管理员权限)"""
# 只有超级管理员才能创建管理员
if role in [UserRole.SUPER_ADMIN, UserRole.ADMIN] and current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权创建管理员账户"
)
if len(password) < 6:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="密码长度不能少于6位"
)
user = UserService.create_user(
db,
username=username,
password=password,
email=email,
phone=phone,
real_name=real_name,
role=role,
created_by=current_user.id
)
return {
"message": "用户创建成功",
"user": {
"id": user.id,
"username": user.username,
"role": user.role.value
}
}
@router.put("/users/{user_id}/status", summary="更新用户状态")
def update_user_status(
user_id: int,
status: UserStatus,
current_user: User = Depends(require_permission("user:edit")),
db: Session = Depends(get_db)
):
"""启用/禁用用户"""
# 不能禁用自己
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="不能禁用自己的账户"
)
user = UserService.update_user_status(db, user_id, status)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return {"message": "用户状态更新成功", "new_status": status.value}
@router.post("/users/{user_id}/unlock", summary="解锁用户")
def unlock_user(
user_id: int,
current_user: User = Depends(require_permission("user:edit")),
db: Session = Depends(get_db)
):
"""解锁被锁定的用户"""
user = UserService.unlock_user(db, user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return {"message": "用户已解锁"}
@router.post("/users/{user_id}/reset-password", summary="重置用户密码")
def reset_user_password(
user_id: int,
new_password: str,
current_user: User = Depends(require_permission("user:edit")),
db: Session = Depends(get_db)
):
"""管理员重置用户密码"""
if len(new_password) < 6:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="密码长度不能少于6位"
)
# 只有超级管理员才能重置其他管理员的密码
target_user = UserService.get_user_by_id(db, user_id)
if not target_user:
raise HTTPException(status_code=404, detail="用户不存在")
if target_user.role in [UserRole.SUPER_ADMIN, UserRole.ADMIN] and current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权重置管理员密码"
)
success = UserService.reset_password(db, user_id, new_password)
if not success:
raise HTTPException(status_code=404, detail="用户不存在")
# 撤销该用户所有现有令牌,迫使其重新登录
TokenService.revoke_all_user_tokens(db, user_id)
return {"message": "密码重置成功,用户所有会话已失效"}
@router.delete("/users/{user_id}", summary="删除用户")
def delete_user(
user_id: int,
current_user: User = Depends(require_permission("user:delete")),
db: Session = Depends(get_db)
):
"""删除用户(软删除,标记为禁用)"""
# 不能删除自己
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="不能删除自己的账户"
)
# 只有超级管理员才能删除管理员
target_user = UserService.get_user_by_id(db, user_id)
if target_user and target_user.role in [UserRole.SUPER_ADMIN, UserRole.ADMIN]:
if current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权删除管理员账户"
)
success = UserService.delete_user(db, user_id)
if not success:
raise HTTPException(status_code=404, detail="用户不存在")
# 撤销所有令牌
TokenService.revoke_all_user_tokens(db, user_id)
return {"message": "用户已删除"}
+209
View File
@@ -0,0 +1,209 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List, Dict, Any, Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.schemas.network import ScanTask, IPAddress
from app.models.network import TaskType, TaskStatus
from app.services.network_service import NetworkService
from app.services.enhanced_scan_service import EnhancedScanService
from app.services.scan_service import ScanService
router = APIRouter(
prefix="/scan",
tags=["增强扫描"],
dependencies=[Depends(get_current_user)],
)
@router.post("/comprehensive/{network_id}", response_model=ScanTask, summary="综合扫描网段")
def comprehensive_scan(
network_id: int,
background_tasks: BackgroundTasks,
enable_ping: bool = True,
enable_arp: bool = True,
enable_dns: bool = True,
timeout: int = 2,
db: Session = Depends(get_db)
):
"""
对指定网段执行综合扫描
- Ping 扫描检测在线状态
- ARP 扫描获取MAC地址
- 反向DNS解析获取主机名
- MAC厂商识别
"""
network = NetworkService.get_by_id(db, network_id)
if not network:
raise HTTPException(status_code=404, detail="网段不存在")
# 创建扫描任务
task = ScanService.create_scan_task(db, TaskType.PING, network_id=network_id)
try:
# 立即执行扫描(后续改为Celery异步)
ScanService.update_task_status(db, task.id, TaskStatus.RUNNING, progress=0)
# 执行综合扫描
results = EnhancedScanService.scan_network(
network.cidr,
enable_ping=enable_ping,
enable_arp=enable_arp,
enable_dns=enable_dns,
timeout=timeout
)
# 更新IP状态
stats = EnhancedScanService.bulk_update_ips_from_scan(db, results)
# 刷新网段统计字段(used_ips/reserved_ips),保证网段列表利用率能即时反映
NetworkService.get_stats(db, network_id)
# 更新任务状态
ScanService.update_task_status(
db,
task.id,
TaskStatus.COMPLETED,
progress=100,
total_count=stats['total_scanned'],
success_count=stats['online_count']
)
except Exception as e:
ScanService.update_task_status(
db,
task.id,
TaskStatus.FAILED,
error_message=str(e)
)
raise HTTPException(status_code=500, detail=f"扫描失败: {str(e)}")
# 重新获取任务状态
task = ScanService.get_task_by_id(db, task.id)
return task
@router.post("/comprehensive/ip/{ip_address}", summary="单个IP综合扫描")
def comprehensive_scan_ip(
ip_address: str,
enable_ping: bool = True,
enable_arp: bool = True,
enable_dns: bool = True,
timeout: int = 2,
db: Session = Depends(get_db)
):
"""
对单个IP执行综合扫描
"""
result = EnhancedScanService.comprehensive_scan(
ip_address,
enable_ping=enable_ping,
enable_arp=enable_arp,
enable_dns=enable_dns,
timeout=timeout
)
# 如果IP在数据库中,更新信息
EnhancedScanService.update_ip_from_scan_result(db, result)
# 刷新所属网段统计字段(used_ips/reserved_ips),保证网段列表利用率能即时反映
db_ip = db.query(IPAddress).filter(IPAddress.ip_address == ip_address).first()
if db_ip:
NetworkService.get_stats(db, db_ip.network_id)
db.commit()
return result
@router.get("/mac/vendor/{mac_address}", summary="MAC地址厂商识别")
def get_mac_vendor(mac_address: str):
"""
根据MAC地址识别厂商
"""
vendor = EnhancedScanService.get_mac_vendor(mac_address)
return {
"mac_address": mac_address,
"vendor": vendor or "Unknown"
}
@router.post("/dns/reverse/{ip_address}", summary="反向DNS解析")
def reverse_dns(ip_address: str, timeout: int = 2):
"""
反向DNS解析获取主机名
"""
hostname = EnhancedScanService.reverse_dns_lookup(ip_address, timeout)
return {
"ip_address": ip_address,
"hostname": hostname or "Unknown"
}
@router.get("/online/ips", summary="获取所有在线IP列表")
def get_online_ips(
network_id: Optional[int] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""
获取所有在线的IP地址列表
"""
from app.models.network import IPAddress as IPAddressModel, IPStatus
query = db.query(IPAddressModel).filter(IPAddressModel.status == IPStatus.ONLINE)
if network_id:
query = query.filter(IPAddressModel.network_id == network_id)
total = query.count()
items = query.order_by(IPAddressModel.ip_address).offset(skip).limit(limit).all()
return {
"total": total,
"online_count": total,
"items": items
}
@router.get("/statistics/summary", summary="扫描统计摘要")
def get_scan_statistics(db: Session = Depends(get_db)):
"""
获取扫描统计摘要信息
"""
from app.models.network import IPAddress as IPAddressModel, IPStatus
from app.models.network import ScanTask, TaskStatus
# IP 统计
total_ips = db.query(IPAddressModel).count()
online_ips = db.query(IPAddressModel).filter(IPAddressModel.status == IPStatus.ONLINE).count()
offline_ips = db.query(IPAddressModel).filter(IPAddressModel.status == IPStatus.OFFLINE).count()
reserved_ips = db.query(IPAddressModel).filter(IPAddressModel.status == IPStatus.RESERVED).count()
with_mac = db.query(IPAddressModel).filter(IPAddressModel.mac_address.isnot(None)).count()
with_hostname = db.query(IPAddressModel).filter(IPAddressModel.hostname.isnot(None)).count()
# 任务统计
total_tasks = db.query(ScanTask).count()
completed_tasks = db.query(ScanTask).filter(ScanTask.status == TaskStatus.COMPLETED).count()
failed_tasks = db.query(ScanTask).filter(ScanTask.status == TaskStatus.FAILED).count()
return {
"ip_statistics": {
"total": total_ips,
"online": online_ips,
"offline": offline_ips,
"reserved": reserved_ips,
"available": total_ips - online_ips - offline_ips - reserved_ips,
"with_mac": with_mac,
"with_hostname": with_hostname,
"online_rate": round(online_ips / total_ips * 100, 2) if total_ips > 0 else 0
},
"task_statistics": {
"total": total_tasks,
"completed": completed_tasks,
"failed": failed_tasks,
"success_rate": round(completed_tasks / total_tasks * 100, 2) if total_tasks > 0 else 0
}
}
+162
View File
@@ -0,0 +1,162 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.auth import User
from app.schemas.network import IPAddressListResponse, IPAddressUpdate, IPAddress
from app.models.network import IPStatus
from app.services.ip_service import IPService
from app.services.audit_service import AuditService, AuditAction
router = APIRouter(
prefix="/ips",
tags=["IP地址管理"],
dependencies=[Depends(get_current_user)],
)
def _client_info(request: Request) -> tuple[str, str]:
return (
request.client.host if request.client else "",
(request.headers.get("user-agent") or "")[:500],
)
@router.get("", response_model=IPAddressListResponse, summary="获取IP列表")
def get_ips(
network_id: Optional[int] = None,
status: Optional[IPStatus] = None,
search: Optional[str] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""获取IP地址列表,支持过滤和搜索"""
total, items = IPService.get_list(
db,
network_id=network_id,
status=status,
skip=skip,
limit=limit,
search=search
)
return {"total": total, "items": items}
@router.get("/{ip_id}", response_model=IPAddress, summary="获取IP详情")
def get_ip(ip_id: int, db: Session = Depends(get_db)):
"""根据ID获取IP详情"""
ip = IPService.get_by_id(db, ip_id)
if not ip:
raise HTTPException(status_code=404, detail="IP不存在")
return ip
@router.get("/address/{ip_address}", response_model=IPAddress, summary="根据IP地址查询")
def get_ip_by_address(ip_address: str, db: Session = Depends(get_db)):
"""根据IP地址查询详细信息"""
ip = IPService.get_by_ip(db, ip_address)
if not ip:
raise HTTPException(status_code=404, detail="IP不存在")
return ip
@router.put("/{ip_id}", response_model=IPAddress, summary="更新IP信息")
def update_ip(
ip_id: int,
ip_in: IPAddressUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""更新IP地址的属性信息"""
old = IPService.get_by_id(db, ip_id)
if not old:
raise HTTPException(status_code=404, detail="IP不存在")
ip = IPService.update(db, ip_id, ip_in)
if not ip:
raise HTTPException(status_code=404, detail="IP不存在")
c, u = _client_info(request)
AuditService.record(
db,
action=AuditAction.IP_UPDATE,
user=current_user,
resource_type="ip",
resource_id=str(ip.id),
resource_name=ip.ip_address,
method="PUT",
path=str(request.url.path),
ip_address=c,
user_agent=u,
detail={
"before": {
"owner": old.owner, "hostname": old.hostname, "mac_address": old.mac_address,
"vendor": old.vendor, "status": old.status.value if old.status else None,
"notes": old.notes, "switch_name": old.switch_name, "switch_port": old.switch_port,
},
"after": {
"owner": ip.owner, "hostname": ip.hostname, "mac_address": ip.mac_address,
"vendor": ip.vendor, "status": ip.status.value if ip.status else None,
"notes": ip.notes, "switch_name": ip.switch_name, "switch_port": ip.switch_port,
},
},
)
return ip
@router.post("/{ip_id}/reserve", response_model=IPAddress, summary="标记为保留IP")
def reserve_ip(
ip_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""将IP标记为保留状态,禁止分配"""
ip = IPService.set_reserved(db, ip_id, reserved=True)
if not ip:
raise HTTPException(status_code=404, detail="IP不存在")
c, u = _client_info(request)
AuditService.record(
db,
action=AuditAction.IP_RESERVE,
user=current_user,
resource_type="ip",
resource_id=str(ip.id),
resource_name=ip.ip_address,
method="POST",
path=str(request.url.path),
ip_address=c,
user_agent=u,
)
return ip
@router.post("/{ip_id}/unreserve", response_model=IPAddress, summary="取消保留IP")
def unreserve_ip(
ip_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""取消IP的保留状态"""
ip = IPService.set_reserved(db, ip_id, reserved=False)
if not ip:
raise HTTPException(status_code=404, detail="IP不存在")
c, u = _client_info(request)
AuditService.record(
db,
action=AuditAction.IP_UNRESERVE,
user=current_user,
resource_type="ip",
resource_id=str(ip.id),
resource_name=ip.ip_address,
method="POST",
path=str(request.url.path),
ip_address=c,
user_agent=u,
)
return ip
+180
View File
@@ -0,0 +1,180 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.auth import User
from app.schemas.network import (
NetworkCreate, NetworkUpdate, Network, NetworkListResponse,
NetworkStats, IPAddressListResponse, IPAddressUpdate, IPAddress
)
from app.services.network_service import NetworkService
from app.services.audit_service import AuditService, AuditAction
router = APIRouter(
prefix="/networks",
tags=["网段管理"],
dependencies=[Depends(get_current_user)],
)
def _client_info(request: Request) -> tuple[str, str]:
return (
request.client.host if request.client else "",
(request.headers.get("user-agent") or "")[:500],
)
@router.get("", response_model=NetworkListResponse, summary="获取网段列表")
def get_networks(
skip: int = 0,
limit: int = 100,
group_name: Optional[str] = None,
db: Session = Depends(get_db)
):
"""获取所有网段列表"""
total, items = NetworkService.get_list(db, skip=skip, limit=limit, group_name=group_name)
return {"total": total, "items": items}
@router.get("/stats", response_model=list[NetworkStats], summary="获取所有网段统计")
def get_all_network_stats(db: Session = Depends(get_db)):
"""获取所有网段的统计信息"""
return NetworkService.get_all_stats(db)
@router.get("/{network_id}", response_model=Network, summary="获取网段详情")
def get_network(network_id: int, db: Session = Depends(get_db)):
"""根据ID获取网段详情"""
network = NetworkService.get_by_id(db, network_id)
if not network:
raise HTTPException(status_code=404, detail="网段不存在")
return network
@router.get("/{network_id}/stats", response_model=NetworkStats, summary="获取网段统计")
def get_network_stats(network_id: int, db: Session = Depends(get_db)):
"""获取指定网段的统计信息"""
stats = NetworkService.get_stats(db, network_id)
if not stats:
raise HTTPException(status_code=404, detail="网段不存在")
return stats
@router.get("/{network_id}/ips", response_model=IPAddressListResponse, summary="获取网段下的IP列表")
def get_network_ips(
network_id: int,
skip: int = 0,
limit: int = 200,
db: Session = Depends(get_db)
):
"""获取指定网段下的所有IP地址"""
from app.services.ip_service import IPService
total, items = IPService.get_list(db, network_id=network_id, skip=skip, limit=limit)
return {"total": total, "items": items}
@router.post("", response_model=Network, status_code=201, summary="创建网段")
def create_network(
network_in: NetworkCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""创建新网段,支持CIDR格式"""
existing = NetworkService.get_by_cidr(db, network_in.cidr)
if existing:
raise HTTPException(status_code=400, detail="该网段已存在")
ip, ua = _client_info(request)
try:
network = NetworkService.create(db, network_in)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
AuditService.record(
db,
action=AuditAction.NETWORK_CREATE,
user=current_user,
resource_type="network",
resource_id=network.id,
resource_name=network.cidr,
method="POST",
path=str(request.url.path),
ip_address=ip,
user_agent=ua,
detail={"cidr": network.cidr, "name": network.name},
)
return network
@router.put("/{network_id}", response_model=Network, summary="更新网段")
def update_network(
network_id: int,
network_in: NetworkUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""更新网段信息"""
old = NetworkService.get_by_id(db, network_id)
if not old:
raise HTTPException(status_code=404, detail="网段不存在")
network = NetworkService.update(db, network_id, network_in)
if not network:
raise HTTPException(status_code=404, detail="网段不存在")
ip, ua = _client_info(request)
AuditService.record(
db,
action=AuditAction.NETWORK_UPDATE,
user=current_user,
resource_type="network",
resource_id=network.id,
resource_name=network.cidr,
method="PUT",
path=str(request.url.path),
ip_address=ip,
user_agent=ua,
detail={
"before": {"name": old.name, "gateway": old.gateway, "group_name": old.group_name, "vlan_id": old.vlan_id, "description": old.description},
"after": {"name": network.name, "gateway": network.gateway, "group_name": network.group_name, "vlan_id": network.vlan_id, "description": network.description},
},
)
return network
@router.delete("/{network_id}", summary="删除网段")
def delete_network(
network_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""删除网段及其下的所有IP记录"""
network = NetworkService.get_by_id(db, network_id)
if not network:
raise HTTPException(status_code=404, detail="网段不存在")
cidr_snapshot = network.cidr
success = NetworkService.delete(db, network_id)
if not success:
raise HTTPException(status_code=404, detail="网段不存在")
ip, ua = _client_info(request)
AuditService.record(
db,
action=AuditAction.NETWORK_DELETE,
user=current_user,
resource_type="network",
resource_id=network_id,
resource_name=cidr_snapshot,
method="DELETE",
path=str(request.url.path),
ip_address=ip,
user_agent=ua,
detail={"cidr": cidr_snapshot},
)
return {"message": "删除成功"}
+108
View File
@@ -0,0 +1,108 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Dict, Any
from app.core.database import get_db
from app.core.security import get_current_user
from app.schemas.network import ScanTask, ScanTaskListResponse, ScanResult
from app.models.network import TaskType, TaskStatus
from app.services.scan_service import ScanService
from app.services.network_service import NetworkService
router = APIRouter(
prefix="/scan",
tags=["扫描管理"],
dependencies=[Depends(get_current_user)],
)
@router.get("/tasks", response_model=ScanTaskListResponse, summary="获取扫描任务列表")
def get_scan_tasks(
skip: int = 0,
limit: int = 50,
db: Session = Depends(get_db)
):
"""获取所有扫描任务"""
total, items = ScanService.get_task_list(db, skip=skip, limit=limit)
return {"total": total, "items": items}
@router.get("/tasks/{task_id}", response_model=ScanTask, summary="获取扫描任务状态")
def get_scan_task(task_id: int, db: Session = Depends(get_db)):
"""获取指定扫描任务的状态"""
task = ScanService.get_task_by_id(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return task
@router.post("/ping/{network_id}", response_model=ScanTask, status_code=201, summary="触发网段Ping扫描")
def trigger_ping_scan(network_id: int, db: Session = Depends(get_db)):
"""对指定网段触发ICMP Ping扫描"""
network = NetworkService.get_by_id(db, network_id)
if not network:
raise HTTPException(status_code=404, detail="网段不存在")
# 创建扫描任务
task = ScanService.create_scan_task(db, TaskType.PING, network_id=network_id)
# 立即执行扫描(简单版,后续用Celery)
try:
ScanService.update_task_status(db, task.id, TaskStatus.RUNNING, progress=0)
# 执行扫描
results = ScanService.ping_network(network.cidr)
# 更新IP状态
online_count, offline_count = ScanService.update_ip_status_from_scan_results(db, results)
# 刷新网段统计字段(used_ips/reserved_ips),保证网段列表利用率能即时反映
NetworkService.get_stats(db, network_id)
# 更新任务状态
ScanService.update_task_status(
db,
task.id,
TaskStatus.COMPLETED,
progress=100,
total_count=len(results),
success_count=online_count,
failed_count=offline_count
)
except Exception as e:
ScanService.update_task_status(
db,
task.id,
TaskStatus.FAILED,
error_message=str(e)
)
raise HTTPException(status_code=500, detail=f"扫描失败: {str(e)}")
# 重新获取任务状态
task = ScanService.get_task_by_id(db, task.id)
return task
@router.post("/ping/single/{ip_address}", response_model=ScanResult, summary="单个IP Ping测试")
def ping_single_ip(ip_address: str):
"""对单个IP执行Ping测试"""
result = ScanService.ping_single_ip(ip_address)
return {
"ip_address": result["ip_address"],
"status": result["status"],
"mac_address": None,
"hostname": None,
"response_time": result.get("response_time")
}
@router.post("/refresh/stats", summary="刷新所有网段统计")
def refresh_all_stats(db: Session = Depends(get_db)):
"""刷新所有网段的统计数据"""
stats = NetworkService.get_all_stats(db)
return {
"message": "统计更新完成",
"networks_updated": len(stats),
"stats": stats
}
+555
View File
@@ -0,0 +1,555 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import Optional, List
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.auth import User
from app.services.snmp_service import SNMPService
from app.services.audit_service import AuditService, AuditAction
router = APIRouter(
prefix="/snmp",
tags=["SNMP 管理"],
dependencies=[Depends(get_current_user)],
)
def _client_info(request: Request) -> tuple[str, str]:
return (
request.client.host if request.client else "",
(request.headers.get("user-agent") or "")[:500],
)
# ========== 凭据管理 ==========
@router.get("/credentials", summary="获取 SNMP 凭据列表")
def get_snmp_credentials(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
from app.models.snmp import SNMPCredential
query = db.query(SNMPCredential)
total = query.count()
items = query.order_by(SNMPCredential.id.desc()).offset(skip).limit(limit).all()
return {"total": total, "items": items}
@router.get("/credentials/{credential_id}", summary="获取凭据详情")
def get_snmp_credential(credential_id: int, db: Session = Depends(get_db)):
from app.models.snmp import SNMPCredential
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
if not credential:
raise HTTPException(status_code=404, detail="凭据不存在")
return credential
@router.post("/credentials", summary="创建 SNMP 凭据")
def create_snmp_credential(
name: str,
version: str,
community_string: Optional[str] = None,
username: Optional[str] = None,
auth_password: Optional[str] = None,
auth_protocol: Optional[str] = None,
priv_password: Optional[str] = None,
priv_protocol: Optional[str] = None,
description: Optional[str] = None,
timeout: int = 3,
retries: int = 2,
request: Request = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.models.snmp import SNMPCredential
existing = db.query(SNMPCredential).filter(SNMPCredential.name == name).first()
if existing:
raise HTTPException(status_code=400, detail="凭据名称已存在")
credential = SNMPCredential(
name=name,
version=version,
community_string=community_string,
username=username,
auth_password=auth_password,
auth_protocol=auth_protocol,
priv_password=priv_password,
priv_protocol=priv_protocol,
description=description,
timeout=timeout,
retries=retries
)
db.add(credential)
db.commit()
db.refresh(credential)
c, u = _client_info(request)
AuditService.record(
db,
action=AuditAction.SNMP_CRED_CREATE,
user=current_user,
resource_type="snmp_credential",
resource_id=credential.id,
resource_name=credential.name,
method="POST",
path=str(request.url.path),
ip_address=c,
user_agent=u,
detail={"version": credential.version, "name": credential.name},
)
return credential
@router.put("/credentials/{credential_id}", summary="更新 SNMP 凭据")
def update_snmp_credential(
credential_id: int,
name: Optional[str] = None,
community_string: Optional[str] = None,
username: Optional[str] = None,
auth_password: Optional[str] = None,
auth_protocol: Optional[str] = None,
priv_password: Optional[str] = None,
priv_protocol: Optional[str] = None,
description: Optional[str] = None,
timeout: Optional[int] = None,
retries: Optional[int] = None,
is_active: Optional[bool] = None,
request: Request = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.models.snmp import SNMPCredential
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
if not credential:
raise HTTPException(status_code=404, detail="凭据不存在")
before = {"name": credential.name, "description": credential.description, "is_active": credential.is_active, "timeout": credential.timeout, "retries": credential.retries}
if name:
credential.name = name
if community_string is not None:
credential.community_string = community_string
if username is not None:
credential.username = username
if auth_password is not None:
credential.auth_password = auth_password
if auth_protocol is not None:
credential.auth_protocol = auth_protocol
if priv_password is not None:
credential.priv_password = priv_password
if priv_protocol is not None:
credential.priv_protocol = priv_protocol
if description is not None:
credential.description = description
if timeout is not None:
credential.timeout = timeout
if retries is not None:
credential.retries = retries
if is_active is not None:
credential.is_active = is_active
db.commit()
db.refresh(credential)
c, u = _client_info(request)
AuditService.record(
db,
action=AuditAction.SNMP_CRED_UPDATE,
user=current_user,
resource_type="snmp_credential",
resource_id=credential.id,
resource_name=credential.name,
method="PUT",
path=str(request.url.path),
ip_address=c,
user_agent=u,
detail={
"before": before,
"after": {"name": credential.name, "description": credential.description, "is_active": credential.is_active, "timeout": credential.timeout, "retries": credential.retries},
},
)
return credential
@router.delete("/credentials/{credential_id}", summary="删除 SNMP 凭据")
def delete_snmp_credential(
credential_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.models.snmp import SNMPCredential, NetworkDevice
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
if not credential:
raise HTTPException(status_code=404, detail="凭据不存在")
devices = db.query(NetworkDevice).filter(NetworkDevice.snmp_credential_id == credential_id).count()
if devices > 0:
raise HTTPException(status_code=400, detail=f"{devices} 个设备正在使用此凭据,无法删除")
name_snapshot = credential.name
db.delete(credential)
db.commit()
c, u = _client_info(request)
AuditService.record(
db,
action=AuditAction.SNMP_CRED_DELETE,
user=current_user,
resource_type="snmp_credential",
resource_id=credential_id,
resource_name=name_snapshot,
method="DELETE",
path=str(request.url.path),
ip_address=c,
user_agent=u,
)
return {"message": "删除成功"}
# ========== 设备管理 ==========
@router.get("/devices", summary="获取网络设备列表")
def get_network_devices(
device_type: Optional[str] = None,
is_active: Optional[bool] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
from app.models.snmp import NetworkDevice, SNMPCredential
query = db.query(NetworkDevice)
if device_type:
query = query.filter(NetworkDevice.device_type == device_type)
if is_active is not None:
query = query.filter(NetworkDevice.is_active == is_active)
total = query.count()
items = query.order_by(NetworkDevice.id.desc()).offset(skip).limit(limit).all()
# 附带凭据名称
credential_ids = {d.snmp_credential_id for d in items if d.snmp_credential_id}
cred_map = {}
if credential_ids:
for c in db.query(SNMPCredential).filter(SNMPCredential.id.in_(credential_ids)).all():
cred_map[c.id] = c.name
serialized = []
for d in items:
item = {
"id": d.id,
"name": d.name,
"description": d.description,
"ip_address": d.ip_address,
"port": d.port,
"device_type": d.device_type.value if hasattr(d.device_type, 'value') else d.device_type,
"vendor": d.vendor,
"model": d.model,
"firmware_version": d.firmware_version,
"serial_number": d.serial_number,
"location": d.location,
"snmp_credential_id": d.snmp_credential_id,
"credential_name": cred_map.get(d.snmp_credential_id),
"is_active": d.is_active,
"last_polled_at": d.last_polled_at.isoformat() if d.last_polled_at else None,
"last_successful_poll": d.last_successful_poll.isoformat() if d.last_successful_poll else None,
"arp_poll_interval": d.arp_poll_interval,
"mac_poll_interval": d.mac_poll_interval,
"interface_poll_interval": d.interface_poll_interval,
"created_at": d.created_at.isoformat() if d.created_at else None,
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
}
serialized.append(item)
return {"total": total, "items": serialized}
@router.get("/devices/{device_id}", summary="获取网络设备详情")
def get_network_device(device_id: int, db: Session = Depends(get_db)):
from app.models.snmp import NetworkDevice
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
return device
@router.post("/devices", summary="创建网络设备")
def create_network_device(
name: str,
ip_address: str,
credential_id: Optional[int] = None,
port: int = 161,
device_type: str = "other",
description: Optional[str] = None,
location: Optional[str] = None,
request: Request = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.models.snmp import NetworkDevice
existing = db.query(NetworkDevice).filter(NetworkDevice.name == name).first()
if existing:
raise HTTPException(status_code=400, detail="设备名称已存在")
device = NetworkDevice(
name=name,
ip_address=ip_address,
port=port,
device_type=device_type,
snmp_credential_id=credential_id,
description=description,
location=location,
is_active=True
)
db.add(device)
db.commit()
db.refresh(device)
c, u = _client_info(request)
AuditService.record(
db,
action=AuditAction.SNMP_DEVICE_CREATE,
user=current_user,
resource_type="snmp_device",
resource_id=device.id,
resource_name=device.name,
method="POST",
path=str(request.url.path),
ip_address=c,
user_agent=u,
detail={"ip_address": device.ip_address, "port": device.port, "device_type": device.device_type.value if hasattr(device.device_type, 'value') else device.device_type},
)
return device
@router.put("/devices/{device_id}", summary="更新网络设备")
def update_network_device(
device_id: int,
name: Optional[str] = None,
ip_address: Optional[str] = None,
credential_id: Optional[int] = None,
port: Optional[int] = None,
device_type: Optional[str] = None,
description: Optional[str] = None,
location: Optional[str] = None,
is_active: Optional[bool] = None,
request: Request = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.models.snmp import NetworkDevice
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
before = {
"name": device.name, "ip_address": device.ip_address, "port": device.port,
"snmp_credential_id": device.snmp_credential_id, "device_type": device.device_type.value if hasattr(device.device_type, 'value') else device.device_type,
"description": device.description, "location": device.location, "is_active": device.is_active,
}
if name:
device.name = name
if ip_address:
device.ip_address = ip_address
if credential_id is not None:
device.snmp_credential_id = credential_id
if port:
device.port = port
if device_type:
device.device_type = device_type
if description is not None:
device.description = description
if location is not None:
device.location = location
if is_active is not None:
device.is_active = is_active
db.commit()
db.refresh(device)
c, u = _client_info(request)
AuditService.record(
db,
action=AuditAction.SNMP_DEVICE_UPDATE,
user=current_user,
resource_type="snmp_device",
resource_id=device.id,
resource_name=device.name,
method="PUT",
path=str(request.url.path),
ip_address=c,
user_agent=u,
detail={
"before": before,
"after": {
"name": device.name, "ip_address": device.ip_address, "port": device.port,
"snmp_credential_id": device.snmp_credential_id, "device_type": device.device_type.value if hasattr(device.device_type, 'value') else device.device_type,
"description": device.description, "location": device.location, "is_active": device.is_active,
},
},
)
return device
@router.delete("/devices/{device_id}", summary="删除网络设备")
def delete_network_device(
device_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.models.snmp import NetworkDevice
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
name_snapshot = device.name
ip_snapshot = device.ip_address
db.delete(device)
db.commit()
c, u = _client_info(request)
AuditService.record(
db,
action=AuditAction.SNMP_DEVICE_DELETE,
user=current_user,
resource_type="snmp_device",
resource_id=device_id,
resource_name=name_snapshot,
method="DELETE",
path=str(request.url.path),
ip_address=c,
user_agent=u,
detail={"ip_address": ip_snapshot},
)
return {"message": "删除成功"}
# ========== SNMP 操作 ==========
@router.post("/devices/{device_id}/test", summary="测试 SNMP 连接")
def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
"""测试设备的 SNMP 连接(不写审计,避免刷屏)"""
from app.models.snmp import NetworkDevice
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
if not device.snmp_credential:
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
success, info = SNMPService.test_connection(device, device.snmp_credential)
return {
"device_id": device_id,
"device_name": device.name,
"success": success,
"info": info
}
@router.post("/devices/{device_id}/poll", summary="立即轮询设备")
def poll_device_now(device_id: int, db: Session = Depends(get_db)):
"""立即轮询设备,获取 ARP 表、MAC 地址表、接口信息(不写审计,常规操作)"""
result = SNMPService.poll_device(db, device_id)
if 'error' in result:
raise HTTPException(status_code=400, detail=result['error'])
return result
@router.post("/devices/{device_id}/arp", summary="获取设备 ARP 表")
def get_device_arp_table(device_id: int, db: Session = Depends(get_db)):
"""获取设备的 ARP 表"""
from app.models.snmp import NetworkDevice, ARPEntry
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
if not device.snmp_credential:
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
entries = SNMPService.get_arp_table(device, device.snmp_credential, db)
return {
"device_id": device_id,
"device_name": device.name,
"entries": entries,
"count": len(entries)
}
@router.get("/arp-entries", summary="查询所有 ARP 记录")
def get_all_arp_entries(
device_id: Optional[int] = None,
ip_address: Optional[str] = None,
mac_address: Optional[str] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""查询 ARP 表数据"""
from app.models.snmp import ARPEntry
query = db.query(ARPEntry)
if device_id:
query = query.filter(ARPEntry.device_id == device_id)
if ip_address:
query = query.filter(ARPEntry.ip_address.like(f'%{ip_address}%'))
if mac_address:
query = query.filter(ARPEntry.mac_address.like(f'%{mac_address}%'))
total = query.count()
items = query.order_by(ARPEntry.last_seen.desc()).offset(skip).limit(limit).all()
return {"total": total, "items": items}
@router.get("/statistics/summary", summary="SNMP 统计摘要")
def get_snmp_statistics(db: Session = Depends(get_db)):
"""获取 SNMP 相关统计"""
from app.models.snmp import SNMPCredential, NetworkDevice, ARPEntry
credential_count = db.query(SNMPCredential).filter(SNMPCredential.is_active == True).count()
device_count = db.query(NetworkDevice).filter(NetworkDevice.is_active == True).count()
arp_count = db.query(ARPEntry).count()
unique_macs = db.query(ARPEntry.mac_address).distinct().count()
from datetime import datetime, timedelta
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
online_devices = db.query(NetworkDevice).filter(
NetworkDevice.is_active == True,
NetworkDevice.last_successful_poll >= one_hour_ago
).count()
return {
"credentials": {
"total": credential_count
},
"devices": {
"total": device_count,
"online_last_hour": online_devices
},
"data": {
"arp_entries": arp_count,
"unique_mac_addresses": unique_macs
}
}
+5
View File
@@ -0,0 +1,5 @@
# app/core/__init__.py
from app.core.config import settings
from app.core.database import Base, engine, get_db, SessionLocal
__all__ = ["settings", "Base", "engine", "get_db", "SessionLocal"]
Binary file not shown.
+35
View File
@@ -0,0 +1,35 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# 应用配置
APP_NAME: str = "IPAM Management System"
APP_VERSION: str = "0.1.0"
DEBUG: bool = True
# 数据库配置
DATABASE_URL: str = "mysql+pymysql://ipam:***@localhost:3308/ipam"
# Redis配置
REDIS_URL: str = "redis://localhost:6379/0"
# Celery配置
CELERY_BROKER_URL: str = "redis://localhost:6379/1"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/2"
# JWT配置
SECRET_KEY: str = "your-super-secret-key-here-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7
# 扫描配置
PING_TIMEOUT: int = 2
PING_RETRIES: int = 2
SCAN_CONCURRENCY: int = 50
class Config:
env_file = ".env"
settings = Settings()
+31
View File
@@ -0,0 +1,31 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# 创建数据库引擎
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
pool_size=10,
max_overflow=20,
echo=settings.DEBUG
)
# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基础模型类
Base = declarative_base()
def get_db():
"""
获取数据库会话的依赖项
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
+158
View File
@@ -0,0 +1,158 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.models.auth import User, UserStatus
# 密码加密上下文
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# OAuth2 认证方案
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login")
class SecurityUtils:
"""安全工具类"""
@staticmethod
def hash_password(password: str) -> str:
"""密码哈希加密"""
return pwd_context.hash(password)
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return pwd_context.verify(plain_password, hashed_password)
@staticmethod
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""创建访问令牌"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
@staticmethod
def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""创建刷新令牌"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(days=7) # 刷新令牌7天过期
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
@staticmethod
def verify_token(token: str, credentials_exception=None) -> Optional[dict]:
"""验证令牌"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
token_type: str = payload.get("type")
if username is None:
raise credentials_exception or HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
return payload
except JWTError:
raise credentials_exception or HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
# 权限校验依赖
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
"""获取当前登录用户"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无法验证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
payload = SecurityUtils.verify_token(token, credentials_exception)
username: str = payload.get("sub")
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
# 检查用户状态
if user.status != UserStatus.ACTIVE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="用户已被禁用或锁定"
)
return user
def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
"""获取当前活跃用户(已废弃,保留兼容)"""
return current_user
class PermissionChecker:
"""权限检查器"""
def __init__(self, required_permission: str):
self.required_permission = required_permission
def __call__(self, current_user: User = Depends(get_current_user)) -> User:
"""检查用户是否拥有指定权限"""
# 超级管理员拥有所有权限
if current_user.role == "super_admin":
return current_user
# 基于角色的权限检查
role_permissions = {
"admin": {
"network", "ip", "scan", "snmp", "alert", "report"
},
"operator": {
"network:view", "ip:view", "ip:edit", "scan:run", "alert:view"
},
"viewer": {
"network:view", "ip:view", "alert:view"
}
}
user_permissions = role_permissions.get(current_user.role, set())
# 检查是否有通配符权限(如 "network" 包含 "network:create"
permission_category = self.required_permission.split(":")[0]
if (
self.required_permission not in user_permissions and
permission_category not in user_permissions and
"*" not in user_permissions
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"缺少权限: {self.required_permission}"
)
return current_user
# 快捷权限检查依赖
def require_permission(permission: str):
"""权限检查依赖函数"""
return PermissionChecker(permission)
+71
View File
@@ -0,0 +1,71 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.core.config import settings
from app.core.database import engine, Base, SessionLocal
from app.api.v1 import api_router
# 导入所有模型确保创建表
from app.models.network import Network, IPAddress, ScanTask
from app.models.snmp import SNMPCredential, NetworkDevice, ARPEntry, MACAddressTable, DeviceInterface
from app.models.alert import Alert, AlertRule, WhitelistedMAC
from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken
from app.models.audit import AuditLog
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时创建数据库表
Base.metadata.create_all(bind=engine)
# 初始化默认管理员账户 + 默认权限
from app.services.user_service import UserService
db = SessionLocal()
try:
UserService.init_default_admin(db)
UserService.init_default_permissions(db)
print("✅ 系统初始化完成,默认管理员账户: admin / admin123")
finally:
db.close()
yield
# 关闭时清理
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="企业级IP地址管理系统",
lifespan=lifespan
)
# CORS 配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册 API 路由
app.include_router(api_router)
@app.get("/")
async def root():
"""根路径"""
return {
"name": settings.APP_NAME,
"version": settings.APP_VERSION,
"docs": "/docs",
"redoc": "/redoc"
}
@app.get("/health")
async def health_check():
"""健康检查"""
return {"status": "healthy"}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+120
View File
@@ -0,0 +1,120 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, Float
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
import enum
class AlertType(str, enum.Enum):
"""告警类型"""
IP_CONFLICT = "ip_conflict" # IP 冲突
UNAUTHORIZED_ACCESS = "unauthorized_access" # 未授权接入
SUBNET_FULL = "subnet_full" # 网段耗尽
SCAN_FAILED = "scan_failed" # 扫描失败
DEVICE_OFFLINE = "device_offline" # 设备离线
NEW_DEVICE_DETECTED = "new_device_detected" # 新设备发现
MAC_CHANGED = "mac_changed" # MAC 地址变化
class AlertSeverity(str, enum.Enum):
"""告警级别"""
INFO = "info" # 信息
WARNING = "warning" # 警告
ERROR = "error" # 错误
CRITICAL = "critical" # 严重
class AlertStatus(str, enum.Enum):
"""告警状态"""
ACTIVE = "active" # 活跃
ACKNOWLEDGED = "acknowledged" # 已确认
RESOLVED = "resolved" # 已解决
IGNORED = "ignored" # 已忽略
class Alert(Base):
"""告警"""
__tablename__ = "alerts"
id = Column(Integer, primary_key=True, index=True)
alert_type = Column(Enum(AlertType), nullable=False, index=True)
severity = Column(Enum(AlertSeverity), default=AlertSeverity.WARNING, index=True)
status = Column(Enum(AlertStatus), default=AlertStatus.ACTIVE, index=True)
title = Column(String(255), nullable=False)
message = Column(Text)
# 关联对象
network_id = Column(Integer, ForeignKey("networks.id"), nullable=True)
ip_address_id = Column(Integer, ForeignKey("ip_addresses.id"), nullable=True)
device_id = Column(Integer, ForeignKey("network_devices.id"), nullable=True)
ip_address_str = Column(String(50), nullable=True) # 冗余存储便于查询
# 关联的 IP/MAC 信息
mac_address = Column(String(50), nullable=True)
conflicting_mac = Column(String(50), nullable=True) # 用于 IP 冲突告警
# 统计信息
usage_percent = Column(Float, nullable=True) # 网段使用率告警时存储
# 处理信息
acknowledged_by = Column(String(100), nullable=True)
acknowledged_at = Column(DateTime(timezone=True), nullable=True)
resolved_by = Column(String(100), nullable=True)
resolved_at = Column(DateTime(timezone=True), nullable=True)
resolution_notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 关系
network = relationship("Network")
ip_address = relationship("IPAddress")
device = relationship("NetworkDevice")
class AlertRule(Base):
"""告警规则配置"""
__tablename__ = "alert_rules"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text, nullable=True)
alert_type = Column(Enum(AlertType), nullable=False)
severity = Column(Enum(AlertSeverity), default=AlertSeverity.WARNING)
# 规则条件
enabled = Column(Boolean, default=True)
# 阈值配置
subnet_usage_threshold = Column(Float, default=90.0) # 网段使用率阈值 %
scan_failure_threshold = Column(Integer, default=3) # 扫描失败次数阈值
inactivity_threshold_minutes = Column(Integer, default=60) # 设备不活动阈值(分钟)
# MAC 白名单(用于未授权接入检测)
mac_whitelist_enabled = Column(Boolean, default=False)
mac_whitelist = Column(Text, nullable=True) # JSON 格式的 MAC 列表
# 通知配置
notify_email = Column(Boolean, default=False)
notify_webhook = Column(Boolean, default=False)
notification_channels = Column(Text, nullable=True) # JSON 格式的通知渠道
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
class WhitelistedMAC(Base):
"""MAC 地址白名单"""
__tablename__ = "whitelisted_macs"
id = Column(Integer, primary_key=True, index=True)
mac_address = Column(String(50), unique=True, nullable=False, index=True)
description = Column(String(255), nullable=True)
owner = Column(String(100), nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+37
View File
@@ -0,0 +1,37 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
from sqlalchemy.sql import func
from app.core.database import Base
class AuditLog(Base):
"""审计日志:记录用户的关键操作"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
# 操作人(nullable:登录失败、token 失效等场景可能没有 user_id)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
username = Column(String(50), index=True, comment="冗余存储用户名,便于 user 被删除后仍能查看")
# 操作内容
action = Column(String(50), nullable=False, index=True, comment="操作类型,如:network.create")
resource_type = Column(String(50), nullable=True, index=True, comment="资源类型:network / ip / snmp / user / scan")
resource_id = Column(String(50), nullable=True, comment="资源 ID(字符串以便兼容多种类型)")
resource_name = Column(String(200), nullable=True, comment="资源名称/标识,便于阅读")
# 请求信息
method = Column(String(10), comment="HTTP 方法")
path = Column(String(500), comment="请求路径")
ip_address = Column(String(50), comment="客户端 IP")
user_agent = Column(String(500), comment="User-Agent")
# 状态
status = Column(String(20), default="success", index=True, comment="success / failed")
detail = Column(Text, comment="变更详情 / 错误信息 / JSON diff")
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
# 复合索引:按时间+用户查、按时间+资源查更高效
Index("idx_audit_logs_user_created", AuditLog.user_id, AuditLog.created_at.desc())
Index("idx_audit_logs_resource", AuditLog.resource_type, AuditLog.resource_id, AuditLog.created_at.desc())
+97
View File
@@ -0,0 +1,97 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
import enum
class UserRole(str, enum.Enum):
"""用户角色枚举"""
SUPER_ADMIN = "super_admin" # 超级管理员 - 所有权限
ADMIN = "admin" # 管理员 - 除用户管理外的所有权限
OPERATOR = "operator" # 操作员 - 可查看和操作,不能配置
VIEWER = "viewer" # 只读用户 - 只能查看
class UserStatus(str, enum.Enum):
"""用户状态"""
ACTIVE = "active" # 正常
INACTIVE = "inactive" # 禁用
LOCKED = "locked" # 锁定(登录失败过多)
class User(Base):
"""用户表"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
email = Column(String(100), unique=True, index=True)
phone = Column(String(20), index=True)
real_name = Column(String(50), comment="真实姓名")
password_hash = Column(String(255), nullable=False)
role = Column(Enum(UserRole), default=UserRole.VIEWER, nullable=False)
status = Column(Enum(UserStatus), default=UserStatus.ACTIVE, nullable=False)
# 登录信息
last_login_at = Column(DateTime(timezone=True))
last_login_ip = Column(String(50))
login_failed_count = Column(Integer, default=0)
# 通知配置
email_notification = Column(Boolean, default=True)
wechat_notification = Column(Boolean, default=False)
dingtalk_notification = Column(Boolean, default=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 关系
creator = relationship("User", remote_side=[id])
class Permission(Base):
"""权限项"""
__tablename__ = "permissions"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(100), unique=True, nullable=False, comment="权限代码,如:network:create")
name = Column(String(100), nullable=False, comment="权限名称")
description = Column(Text)
category = Column(String(50), comment="权限分类:network, ip, scan, snmp, alert, system")
created_at = Column(DateTime(timezone=True), server_default=func.now())
class RolePermission(Base):
"""角色权限关联表"""
__tablename__ = "role_permissions"
id = Column(Integer, primary_key=True, index=True)
role = Column(Enum(UserRole), nullable=False)
permission_id = Column(Integer, ForeignKey("permissions.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class RefreshToken(Base):
"""刷新令牌"""
__tablename__ = "refresh_tokens"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
token = Column(String(500), unique=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
ip_address = Column(String(50))
user_agent = Column(String(500))
revoked = Column(Boolean, default=False)
revoked_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
# 关系
user = relationship("User")
+119
View File
@@ -0,0 +1,119 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, ForeignKey, JSON, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
import enum
class NetworkGroup(str, enum.Enum):
"""网段分组枚举"""
PRODUCTION = "production"
OFFICE = "office"
TEST = "test"
MANAGEMENT = "management"
OTHER = "other"
class IPStatus(str, enum.Enum):
"""IP状态枚举"""
AVAILABLE = "available"
ONLINE = "online"
OFFLINE = "offline"
RESERVED = "reserved"
class TaskStatus(str, enum.Enum):
"""任务状态枚举"""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class TaskType(str, enum.Enum):
"""任务类型枚举"""
PING = "ping"
ARP = "arp"
SNMP = "snmp"
class Network(Base):
"""网段模型"""
__tablename__ = "networks"
id = Column(Integer, primary_key=True, index=True)
cidr = Column(String(50), unique=True, index=True, nullable=False) # 如: 192.168.1.0/24
name = Column(String(100), index=True)
description = Column(Text, nullable=True)
group_name = Column(String(100), index=True, nullable=True) # 分组名称
gateway = Column(String(50), nullable=True)
vlan_id = Column(Integer, nullable=True)
total_ips = Column(Integer, default=0)
used_ips = Column(Integer, default=0)
reserved_ips = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 关系
ip_addresses = relationship("IPAddress", back_populates="network", cascade="all, delete-orphan")
scan_tasks = relationship("ScanTask", back_populates="network")
class IPAddress(Base):
"""IP地址模型"""
__tablename__ = "ip_addresses"
id = Column(Integer, primary_key=True, index=True)
network_id = Column(Integer, ForeignKey("networks.id"), nullable=False)
ip_address = Column(String(50), unique=True, index=True, nullable=False)
mac_address = Column(String(50), nullable=True, index=True)
hostname = Column(String(255), nullable=True)
status = Column(Enum(IPStatus), default=IPStatus.AVAILABLE)
# 业务属性
owner = Column(String(100), nullable=True)
business_type = Column(String(50), nullable=True)
notes = Column(Text, nullable=True)
custom_fields = Column(JSON, default=dict)
# 扫描信息
last_seen = Column(DateTime(timezone=True), nullable=True)
first_seen = Column(DateTime(timezone=True), server_default=func.now())
# 物理位置信息
switch_name = Column(String(100), nullable=True)
switch_port = Column(String(50), nullable=True)
vendor = Column(String(100), nullable=True) # MAC OUI 解析的厂商
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 关系
network = relationship("Network", back_populates="ip_addresses")
class ScanTask(Base):
"""扫描任务模型"""
__tablename__ = "scan_tasks"
id = Column(Integer, primary_key=True, index=True)
network_id = Column(Integer, ForeignKey("networks.id"), nullable=True)
task_type = Column(Enum(TaskType), nullable=False)
status = Column(Enum(TaskStatus), default=TaskStatus.PENDING)
progress = Column(Integer, default=0) # 0-100
celery_task_id = Column(String(100), nullable=True)
# 统计信息
total_count = Column(Integer, default=0)
success_count = Column(Integer, default=0)
failed_count = Column(Integer, default=0)
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# 关系
network = relationship("Network", back_populates="scan_tasks")
+190
View File
@@ -0,0 +1,190 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
import enum
class SNMPVersion(str, enum.Enum):
"""SNMP 版本"""
V2C = "v2c"
V3 = "v3"
class SNMPAuthProtocol(str, enum.Enum):
"""SNMP V3 认证协议"""
MD5 = "md5"
SHA = "sha"
SHA256 = "sha256"
class SNMPPrivProtocol(str, enum.Enum):
"""SNMP V3 加密协议"""
DES = "des"
AES = "aes"
AES256 = "aes256"
class DeviceType(str, enum.Enum):
"""设备类型"""
ROUTER = "router"
CORE_SWITCH = "core_switch"
DISTRIBUTION_SWITCH = "distribution_switch"
ACCESS_SWITCH = "access_switch"
FIREWALL = "firewall"
LOAD_BALANCER = "load_balancer"
OTHER = "other"
class SNMPCredential(Base):
"""SNMP 凭据"""
__tablename__ = "snmp_credentials"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, index=True, nullable=False)
description = Column(Text, nullable=True)
# 版本
version = Column(Enum(SNMPVersion), default=SNMPVersion.V2C, nullable=False)
# V2C 配置
community_string = Column(String(255), nullable=True)
# V3 配置
username = Column(String(100), nullable=True)
auth_password = Column(String(255), nullable=True)
auth_protocol = Column(Enum(SNMPAuthProtocol), nullable=True)
priv_password = Column(String(255), nullable=True)
priv_protocol = Column(Enum(SNMPPrivProtocol), nullable=True)
# 超时和重试
timeout = Column(Integer, default=3)
retries = Column(Integer, default=2)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 关系
devices = relationship("NetworkDevice", back_populates="snmp_credential")
class NetworkDevice(Base):
"""网络设备"""
__tablename__ = "network_devices"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, index=True, nullable=False)
description = Column(Text, nullable=True)
# 连接信息
ip_address = Column(String(50), index=True, nullable=False)
port = Column(Integer, default=161)
# 设备信息
device_type = Column(Enum(DeviceType), default=DeviceType.OTHER)
vendor = Column(String(100), nullable=True)
model = Column(String(100), nullable=True)
firmware_version = Column(String(100), nullable=True)
serial_number = Column(String(100), nullable=True)
location = Column(String(200), nullable=True)
# SNMP 凭据
snmp_credential_id = Column(Integer, ForeignKey("snmp_credentials.id"), nullable=True)
# 状态
is_active = Column(Boolean, default=True)
last_polled_at = Column(DateTime(timezone=True), nullable=True)
last_successful_poll = Column(DateTime(timezone=True), nullable=True)
# 采集配置
arp_poll_interval = Column(Integer, default=300) # ARP表采集间隔(秒)
mac_poll_interval = Column(Integer, default=300) # MAC表采集间隔(秒)
interface_poll_interval = Column(Integer, default=300) # 接口信息采集间隔
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 关系
snmp_credential = relationship("SNMPCredential", back_populates="devices")
arp_entries = relationship("ARPEntry", back_populates="device", cascade="all, delete-orphan")
mac_entries = relationship("MACAddressTable", back_populates="device", cascade="all, delete-orphan")
interfaces = relationship("DeviceInterface", back_populates="device", cascade="all, delete-orphan")
class ARPEntry(Base):
"""ARP 表条目 (IP -> MAC 映射)"""
__tablename__ = "arp_entries"
id = Column(Integer, primary_key=True, index=True)
device_id = Column(Integer, ForeignKey("network_devices.id"), nullable=False)
ip_address = Column(String(50), index=True, nullable=False)
mac_address = Column(String(50), index=True, nullable=False)
interface = Column(String(100), nullable=True) # 接口名称/索引
vlan_id = Column(Integer, nullable=True)
# 老化信息
age = Column(Integer, nullable=True) # 秒
is_static = Column(Boolean, default=False)
discovered_at = Column(DateTime(timezone=True), server_default=func.now())
last_seen = Column(DateTime(timezone=True), server_default=func.now())
# 关系
device = relationship("NetworkDevice", back_populates="arp_entries")
class MACAddressTable(Base):
"""MAC 地址表 (MAC -> 端口 映射)"""
__tablename__ = "mac_address_table"
id = Column(Integer, primary_key=True, index=True)
device_id = Column(Integer, ForeignKey("network_devices.id"), nullable=False)
mac_address = Column(String(50), index=True, nullable=False)
vlan_id = Column(Integer, nullable=True)
interface = Column(String(100), nullable=True) # 接口名称
port_number = Column(Integer, nullable=True) # 端口号
# 类型: learned, static, management, etc.
entry_type = Column(String(50), nullable=True)
discovered_at = Column(DateTime(timezone=True), server_default=func.now())
last_seen = Column(DateTime(timezone=True), server_default=func.now())
# 关系
device = relationship("NetworkDevice", back_populates="mac_entries")
class DeviceInterface(Base):
"""设备接口信息"""
__tablename__ = "device_interfaces"
id = Column(Integer, primary_key=True, index=True)
device_id = Column(Integer, ForeignKey("network_devices.id"), nullable=False)
if_index = Column(Integer, nullable=False) # ifIndex
if_name = Column(String(100), nullable=True) # ifName
if_descr = Column(String(255), nullable=True) # ifDescr
if_type = Column(String(50), nullable=True) # ifType
if_mtu = Column(Integer, nullable=True) # ifMtu
if_speed = Column(Integer, nullable=True) # ifSpeed (bps)
if_phys_address = Column(String(50), nullable=True) # ifPhysAddress (MAC)
if_admin_status = Column(String(50), nullable=True) # up/down/testing
if_oper_status = Column(String(50), nullable=True) # up/down/testing
# IP 地址
ip_address = Column(String(50), nullable=True)
ip_netmask = Column(String(50), nullable=True)
# 统计
if_in_octets = Column(Integer, nullable=True)
if_out_octets = Column(Integer, nullable=True)
if_in_errors = Column(Integer, nullable=True)
if_out_errors = Column(Integer, nullable=True)
last_polled_at = Column(DateTime(timezone=True), nullable=True)
# 关系
device = relationship("NetworkDevice", back_populates="interfaces")
+173
View File
@@ -0,0 +1,173 @@
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
import ipaddress
from app.models.network import IPStatus, TaskStatus, TaskType
# ========== 网段相关 Schemas ==========
class NetworkBase(BaseModel):
cidr: str = Field(..., description="网段CIDR,如: 192.168.1.0/24")
name: Optional[str] = Field(None, max_length=100, description="网段名称")
description: Optional[str] = Field(None, description="描述")
group_name: Optional[str] = Field(None, max_length=100, description="分组名称")
gateway: Optional[str] = Field(None, max_length=50, description="网关地址")
vlan_id: Optional[int] = Field(None, description="VLAN ID")
@field_validator('cidr')
@classmethod
def validate_cidr(cls, v):
try:
network = ipaddress.ip_network(v, strict=False)
return str(network)
except ValueError:
raise ValueError(f"无效的CIDR格式: {v}")
@field_validator('gateway')
@classmethod
def validate_gateway(cls, v):
if v:
try:
ipaddress.ip_address(v)
except ValueError:
raise ValueError(f"无效的IP地址: {v}")
return v
class NetworkCreate(NetworkBase):
pass
class NetworkUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
group_name: Optional[str] = None
gateway: Optional[str] = None
vlan_id: Optional[int] = None
class Network(NetworkBase):
id: int
total_ips: int
used_ips: int
reserved_ips: int
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
class NetworkStats(BaseModel):
"""网段统计信息"""
id: int
cidr: str
name: Optional[str]
total_ips: int
used_ips: int
reserved_ips: int
available_ips: int
usage_percent: float
group_name: Optional[str]
vlan_id: Optional[int]
class Config:
from_attributes = True
class NetworkListResponse(BaseModel):
total: int
items: List[Network]
# ========== IP地址相关 Schemas ==========
class IPAddressBase(BaseModel):
ip_address: str
mac_address: Optional[str] = None
hostname: Optional[str] = None
owner: Optional[str] = None
business_type: Optional[str] = None
notes: Optional[str] = None
custom_fields: Dict[str, Any] = Field(default_factory=dict)
switch_name: Optional[str] = None
switch_port: Optional[str] = None
vendor: Optional[str] = None
class IPAddressCreate(IPAddressBase):
network_id: int
status: IPStatus = IPStatus.AVAILABLE
class IPAddressUpdate(BaseModel):
mac_address: Optional[str] = None
hostname: Optional[str] = None
status: Optional[IPStatus] = None
owner: Optional[str] = None
business_type: Optional[str] = None
notes: Optional[str] = None
custom_fields: Optional[Dict[str, Any]] = None
class IPAddress(IPAddressBase):
id: int
network_id: int
status: IPStatus
last_seen: Optional[datetime]
first_seen: datetime
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
class IPAddressListResponse(BaseModel):
total: int
items: List[IPAddress]
# ========== 扫描任务相关 Schemas ==========
class ScanTaskBase(BaseModel):
task_type: TaskType
network_id: Optional[int] = None
class ScanTaskCreate(ScanTaskBase):
pass
class ScanTask(BaseModel):
id: int
network_id: Optional[int]
task_type: TaskType
status: TaskStatus
progress: int
celery_task_id: Optional[str]
total_count: int
success_count: int
failed_count: int
started_at: Optional[datetime]
completed_at: Optional[datetime]
error_message: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class ScanTaskListResponse(BaseModel):
total: int
items: List[ScanTask]
class ScanResult(BaseModel):
"""单次扫描结果"""
ip_address: str
status: str
mac_address: Optional[str] = None
hostname: Optional[str] = None
response_time: Optional[float] = None
+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)
+4
View File
@@ -0,0 +1,4 @@
# app/tasks/__init__.py
from app.tasks.celery_app import celery_app
__all__ = ['celery_app']
+55
View File
@@ -0,0 +1,55 @@
from celery import Celery
from celery.schedules import crontab
from app.core.config import settings
# 创建 Celery 应用
celery_app = Celery(
'ipam_tasks',
broker=settings.CELERY_BROKER_URL,
backend=settings.CELERY_RESULT_BACKEND
)
# 配置
celery_app.conf.update(
task_serializer='json',
accept_content=['json'],
result_serializer='json',
timezone='Asia/Shanghai',
enable_utc=True,
task_track_started=True,
task_time_limit=30 * 60, # 任务超时30分钟
worker_prefetch_multiplier=1,
worker_max_tasks_per_child=1000,
)
# 必须在配置后导入并注册任务
from app.tasks.scan_tasks import register_tasks
_tasks = register_tasks(celery_app)
# 导出任务函数
scan_network_task = _tasks['scan_network_task']
scan_single_ip_task = _tasks['scan_single_ip_task']
full_network_scan = _tasks['full_network_scan']
update_all_statistics = _tasks['update_all_statistics']
quick_status_update = _tasks['quick_status_update']
# 定时任务配置
celery_app.conf.beat_schedule = {
# 每小时执行一次全量扫描
'full-scan-every-hour': {
'task': 'app.tasks.scan_tasks.full_network_scan',
'schedule': 3600.0, # 每小时
'args': (True, True, True) # ping, arp, dns
},
# 每天凌晨2点执行一次完整扫描
'full-scan-daily': {
'task': 'app.tasks.scan_tasks.full_network_scan',
'schedule': crontab(hour=2, minute=0),
'args': (True, True, True)
},
# 每15分钟更新一次统计
'update-stats-every-15min': {
'task': 'app.tasks.scan_tasks.update_all_statistics',
'schedule': 900.0, # 15分钟
},
}
+215
View File
@@ -0,0 +1,215 @@
from typing import List, Dict, Any, Optional
from celery import Task
import logging
from app.core.database import SessionLocal
from app.services.network_service import NetworkService
from app.services.enhanced_scan_service import EnhancedScanService
from app.services.scan_service import ScanService
from app.models.network import TaskStatus, ScanTask, IPAddress
logger = logging.getLogger(__name__)
def register_tasks(celery_app):
"""
注册任务到 Celery 应用
"""
@celery_app.task(bind=True)
def scan_network_task(self, network_id: int,
enable_ping: bool = True,
enable_arp: bool = True,
enable_dns: bool = True,
timeout: int = 2):
"""
Celery 异步任务:扫描指定网段
"""
task_id = self.request.id
db = SessionLocal()
try:
# 获取网段信息
network = NetworkService.get_by_id(db, network_id)
if not network:
logger.error(f"网段 {network_id} 不存在")
return {'status': 'failed', 'error': '网段不存在'}
logger.info(f"开始扫描网段: {network.cidr}")
# 更新任务状态为运行中
# 查找已存在的任务记录
db_task = db.query(ScanTask).filter(ScanTask.celery_task_id == task_id).first()
if not db_task:
db_task = ScanService.create_scan_task(db, 'ping', network_id)
db_task.celery_task_id = task_id
db.commit()
db.refresh(db_task)
ScanService.update_task_status(db, db_task.id, TaskStatus.RUNNING, progress=0)
# 执行扫描
results = EnhancedScanService.scan_network(
network.cidr,
enable_ping=enable_ping,
enable_arp=enable_arp,
enable_dns=enable_dns,
timeout=timeout
)
# 批量更新IP信息
stats = EnhancedScanService.bulk_update_ips_from_scan(db, results)
# 更新任务状态
ScanService.update_task_status(
db,
db_task.id,
TaskStatus.COMPLETED,
progress=100,
total_count=stats['total_scanned'],
success_count=stats['online_count']
)
logger.info(f"网段 {network.cidr} 扫描完成: 在线 {stats['online_count']} 台, "
f"发现MAC {stats['mac_found_count']} 个, 主机名 {stats['hostname_found_count']}")
return {
'status': 'completed',
'network_id': network_id,
'cidr': network.cidr,
'statistics': stats
}
except Exception as e:
logger.error(f"扫描失败: {str(e)}", exc_info=True)
if 'db_task' in locals() and db_task:
ScanService.update_task_status(
db,
db_task.id,
TaskStatus.FAILED,
error_message=str(e)
)
raise
finally:
db.close()
@celery_app.task(bind=True)
def scan_single_ip_task(self, ip_address: str,
enable_ping: bool = True,
enable_arp: bool = True,
enable_dns: bool = True):
"""
Celery 异步任务:扫描单个IP
"""
db = SessionLocal()
try:
result = EnhancedScanService.comprehensive_scan(
ip_address,
enable_ping=enable_ping,
enable_arp=enable_arp,
enable_dns=enable_dns
)
# 更新IP信息
EnhancedScanService.update_ip_from_scan_result(db, result)
db.commit()
return result
finally:
db.close()
@celery_app.task(bind=True)
def full_network_scan(self, enable_ping: bool = True,
enable_arp: bool = True,
enable_dns: bool = True):
"""
Celery 定时任务:扫描所有网段
"""
db = SessionLocal()
try:
_, all_networks = NetworkService.get_list(db, limit=1000)
total_stats = {
'total_networks': len(all_networks),
'total_ips': 0,
'online_count': 0,
'mac_found_count': 0,
'hostname_found_count': 0
}
logger.info(f"开始全量扫描: {len(all_networks)} 个网段")
for network in all_networks:
try:
results = EnhancedScanService.scan_network(
network.cidr,
enable_ping=enable_ping,
enable_arp=enable_arp,
enable_dns=enable_dns
)
stats = EnhancedScanService.bulk_update_ips_from_scan(db, results)
total_stats['total_ips'] += stats['total_scanned']
total_stats['online_count'] += stats['online_count']
total_stats['mac_found_count'] += stats['mac_found_count']
total_stats['hostname_found_count'] += stats['hostname_found_count']
logger.info(f"网段 {network.cidr} 扫描完成: 在线 {stats['online_count']}")
except Exception as e:
logger.error(f"网段 {network.cidr} 扫描失败: {str(e)}")
logger.info(f"全量扫描完成: {total_stats}")
return {
'status': 'completed',
'statistics': total_stats
}
finally:
db.close()
@celery_app.task(bind=True)
def update_all_statistics(self):
"""
定时任务:更新所有网段统计信息
"""
db = SessionLocal()
try:
NetworkService.get_all_stats(db)
logger.info("所有网段统计信息已更新")
return {'status': 'completed'}
finally:
db.close()
@celery_app.task(bind=True)
def quick_status_update(self, ip_addresses: List[str]):
"""
快速更新多个IP的状态(用于实时扫描)
"""
db = SessionLocal()
try:
results = []
for ip in ip_addresses:
result = EnhancedScanService.comprehensive_scan(ip, enable_ping=True, enable_arp=True, enable_dns=False)
EnhancedScanService.update_ip_from_scan_result(db, result)
results.append(result)
db.commit()
return {
'scanned_count': len(ip_addresses),
'online_count': sum(1 for r in results if r.get('success')),
'results': results
}
finally:
db.close()
# 返回任务函数供外部使用
return {
'scan_network_task': scan_network_task,
'scan_single_ip_task': scan_single_ip_task,
'full_network_scan': full_network_scan,
'update_all_statistics': update_all_statistics,
'quick_status_update': quick_status_update
}
+16
View File
@@ -0,0 +1,16 @@
fastapi==0.110.0
uvicorn[standard]==0.27.1
sqlalchemy==2.0.28
pymysql==1.1.0
cryptography==42.0.5
pydantic==2.6.4
pydantic-settings==2.2.1
celery==5.3.6
redis==5.0.3
python-multipart==0.0.9
alembic==1.13.1
pysnmp==7.1.15
scapy==2.5.0
python-dotenv==1.0.1
passlib[bcrypt]==1.7.4
httpx==0.27.0