commit 1e1e5957e8bd13f8a5571a675e92293108ae94e5 Author: Your Name Date: Sat Jul 18 09:45:09 2026 +0800 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 记录 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7102dfc --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg +*.egg-info/ +dist/ +build/ +venv/ +.env + +# Node +node_modules/ +dist/ + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +/tmp/ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..679080c --- /dev/null +++ b/README.md @@ -0,0 +1,277 @@ +# IPAM 管理系统 - 完整功能 + +企业级 IP 地址管理系统,支持自动化扫描、发现和管理网络 IP 资产。 + +## ✅ 已完成功能 + +### 🎯 P0 - 核心功能 +- ✅ **FastAPI 后端框架** - 高性能 RESTful API +- ✅ **MySQL 数据库** - 持久化存储 +- ✅ **Redis 缓存** - 任务队列和缓存 +- ✅ **网段管理** - CIDR 网段增删改查,自动创建 IP 记录 +- ✅ **IP 资产台账** - IP 地址全生命周期管理 +- ✅ **Ping 扫描引擎** - ICMP 在线状态检测 + +### 🚀 P1 - 高级功能 +- ✅ **ARP 扫描** - 获取 MAC 地址 +- ✅ **反向 DNS 解析** - 获取主机名 +- ✅ **MAC 厂商识别** - 内置 OUI 数据库 +- ✅ **Celery 异步任务** - 后台并发扫描 +- ✅ **SNMP 设备集成** + - SNMP v2c/v3 支持 + - 设备连接测试 + - ARP 表自动采集 + - 接口信息采集 +- ✅ **告警与异常检测** + - IP 冲突告警 + - 未授权接入检测 + - 网段耗尽预警 + - 设备离线告警 + - 新设备发现告警 + - MAC 地址白名单 + +### 🎨 P2 - 前端界面 +- ✅ **Vue 3 + Element Plus** - 现代化 UI 框架 +- ✅ **仪表盘** - 统计概览、网段使用率、告警统计 +- ✅ **网段管理** - 网段 CRUD、扫描、导出 +- ✅ **IP 地址管理** - 筛选、搜索、编辑 +- ✅ **扫描管理** - 综合扫描、结果导出 +- ✅ **SNMP 管理** - 设备、凭据、ARP 表 +- ✅ **告警中心** - 告警列表、统计、白名单 + +## 🚀 快速启动 + +### 方式一:使用启动脚本(推荐) + +```bash +cd /root/ipam + +# 启动所有服务 +chmod +x start.sh +./start.sh +``` + +### 方式二:手动启动 + +**1. 启动数据库和缓存** +```bash +# MySQL 已运行在 3308 端口 +# Redis 已运行在 6379 端口 +``` + +**2. 启动后端服务** +```bash +cd /root/ipam/backend +source venv/bin/activate +uvicorn app.main:app --host 0.0.0.0 --port 8008 +``` + +**3. 启动 Celery Worker** +```bash +cd /root/ipam/backend +source venv/bin/activate +celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4 +``` + +**4. 启动前端服务** +```bash +cd /root/ipam/frontend +npm run dev -- --host 0.0.0.0 --port 3000 +``` + +## 🌐 访问地址 + +| 服务 | 地址 | 说明 | +|------|------|------| +| 前端界面 | http://服务器IP:3000 | Web 管理界面 | +| 后端 API | http://服务器IP:8008 | API 服务 | +| API 文档 | http://服务器IP:8008/docs | Swagger 在线文档 | +| MySQL | 服务器IP:3308 | 数据库 | +| Redis | 服务器IP:6379 | 缓存和任务队列 | + +## 📖 API 使用示例 + +### 1. 创建网段 +```bash +curl -X POST http://localhost:8008/api/v1/networks \ + -H "Content-Type: application/json" \ + -d '{ + "cidr": "192.168.1.0/24", + "name": "办公网络", + "group_name": "office", + "gateway": "192.168.1.1" + }' +``` + +### 2. 触发扫描 +```bash +curl -X POST http://localhost:8008/api/v1/scan/comprehensive/1 +``` + +### 3. 查看统计 +```bash +curl http://localhost:8008/api/v1/networks/stats +``` + +## 🏗️ 项目结构 + +``` +/root/ipam/ +├── backend/ # 后端代码 +│ ├── app/ +│ │ ├── api/ # API 路由 +│ │ │ └── v1/ +│ │ │ ├── networks.py # 网段管理 +│ │ │ ├── ips.py # IP管理 +│ │ │ ├── scan.py # 扫描管理 +│ │ │ ├── enhanced_scan.py # 增强扫描 +│ │ │ ├── snmp.py # SNMP管理 +│ │ │ └── alerts.py # 告警管理 +│ │ ├── core/ # 核心配置 +│ │ │ ├── config.py # 配置文件 +│ │ │ └── database.py # 数据库连接 +│ │ ├── models/ # 数据模型 +│ │ │ ├── network.py # 网段/IP/扫描任务 +│ │ │ ├── snmp.py # SNMP设备/凭据 +│ │ │ └── alert.py # 告警模型 +│ │ ├── schemas/ # Pydantic 验证模型 +│ │ │ └── network.py +│ │ ├── services/ # 业务逻辑 +│ │ │ ├── network_service.py # 网段服务 +│ │ │ ├── scan_service.py # Ping扫描服务 +│ │ │ ├── enhanced_scan_service.py # ARP/DNS扫描服务 +│ │ │ ├── snmp_service.py # SNMP服务 +│ │ │ └── alert_service.py # 告警服务 +│ │ ├── tasks/ # Celery 任务 +│ │ │ ├── celery_app.py # Celery 应用 +│ │ │ └── scan_tasks.py # 扫描任务 +│ │ └── main.py # FastAPI 应用入口 +│ ├── requirements.txt # Python 依赖 +│ └── venv/ # Python 虚拟环境 +├── frontend/ # 前端代码 +│ ├── src/ +│ │ ├── views/ # 页面组件 +│ │ │ ├── Dashboard.vue # 仪表盘 +│ │ │ ├── Networks.vue # 网段管理 +│ │ │ ├── IPs.vue # IP地址 +│ │ │ ├── Scan.vue # 扫描管理 +│ │ │ ├── SNMP.vue # SNMP管理 +│ │ │ └── Alerts.vue # 告警中心 +│ │ ├── layout/ # 布局组件 +│ │ │ └── MainLayout.vue # 主布局 +│ │ ├── router/ # 路由配置 +│ │ │ └── index.js +│ │ ├── api/ # API 客户端 +│ │ │ └── index.js +│ │ ├── utils/ # 工具函数 +│ │ │ └── api.js +│ │ ├── App.vue # 根组件 +│ │ └── main.js # 应用入口 +│ ├── package.json +│ └── vite.config.js # Vite 配置 +├── docs/ # 文档 +├── docker-compose.yml # Docker 配置 +├── start.sh # 启动脚本 +└── README.md +``` + +## 🔧 技术栈 + +### 后端 +- **Web 框架**: FastAPI 0.110 +- **ORM**: SQLAlchemy 2.0 +- **数据库**: MySQL 8.0 +- **缓存/队列**: Redis 7.0 +- **异步任务**: Celery 5.3 +- **SNMP**: PySNMP 4.4.12 +- **网络探测**: Scapy 2.5.0 +- **HTTP 客户端**: httpx + +### 前端 +- **框架**: Vue 3.5 +- **UI 组件**: Element Plus 2.14 +- **路由**: Vue Router 4.6 +- **状态管理**: Pinia 4.0 +- **HTTP 客户端**: Axios 1.18 +- **构建工具**: Vite 5.0 + +## 📊 数据库表 + +| 表名 | 说明 | +|------|------| +| networks | 网段信息 | +| ip_addresses | IP 地址台账 | +| scan_tasks | 扫描任务记录 | +| snmp_credentials | SNMP 凭据配置 | +| network_devices | 网络设备列表 | +| arp_entries | ARP 表记录 | +| mac_address_table | MAC 地址表 | +| device_interfaces | 设备接口信息 | +| alerts | 告警记录 | +| alert_rules | 告警规则配置 | +| whitelisted_macs | MAC 地址白名单 | + +## 🎯 核心特性 + +### 1. 智能扫描 +- **ICMP Ping**: 快速检测在线状态 +- **ARP 扫描**: 二层网络 MAC 发现 +- **反向 DNS**: 自动解析主机名 +- **并发扫描**: Celery 异步执行,不阻塞 Web 服务 + +### 2. SNMP 设备管理 +- 支持 SNMP v2c/v3 +- 自动采集 ARP 表、接口信息 +- 设备连通性测试 +- 轮询状态监控 + +### 3. 智能告警 +- IP 冲突自动检测 +- 未授权设备接入告警 +- 网段使用率预警 +- MAC 白名单机制 + +### 4. 厂商识别 +- 内置 IEEE OUI 数据库 +- 自动识别 3000+ 设备厂商 +- 支持 Cisco、H3C、华为、华三等主流厂商 + +## 📝 开发计划 + +### 待实现功能 (P3) +- [ ] 历史趋势图表 +- [ ] 报告导出 +- [ ] 用户权限管理 +- [ ] 邮件/钉钉/企业微信通知 +- [ ] VLAN 自动发现 +- [ ] 拓扑图自动生成 +- [ ] 端口控制(关闭/开启) +- [ ] 资产变更审计日志 + +## 🔐 安全建议 + +1. **修改默认密码** + - MySQL root 密码 + - Redis 密码配置 + +2. **配置防火墙** + - 仅开放必要端口:3000(前端)、8008(API) + - 限制 3308(MySQL)、6379(Redis) 访问来源 + +3. **HTTPS** + - 生产环境建议配置 Nginx 反向代理 + SSL + +4. **访问控制** + - 配置 API 认证 (JWT) + - 限制管理后台访问来源 + +## 📞 技术支持 + +如有问题或建议,请查看 API 文档: +- Swagger UI: http://服务器IP:8008/docs +- ReDoc: http://服务器IP:8008/redoc + +--- + +**版本**: 1.0.0 +**最后更新**: 2026-07-17 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..94b47d3 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f7b2739 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..bd21cdf --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# app/__init__.py diff --git a/backend/app/__pycache__/__init__.cpython-311.pyc b/backend/app/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..b38c3ab Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/app/__pycache__/main.cpython-311.pyc b/backend/app/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..0625e84 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-311.pyc differ diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..ab9ad83 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -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) diff --git a/backend/app/api/v1/__pycache__/__init__.cpython-311.pyc b/backend/app/api/v1/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..4338c0e Binary files /dev/null and b/backend/app/api/v1/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/alerts.cpython-311.pyc b/backend/app/api/v1/__pycache__/alerts.cpython-311.pyc new file mode 100644 index 0000000..5ddfd0d Binary files /dev/null and b/backend/app/api/v1/__pycache__/alerts.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/async_scan.cpython-311.pyc b/backend/app/api/v1/__pycache__/async_scan.cpython-311.pyc new file mode 100644 index 0000000..3f395f2 Binary files /dev/null and b/backend/app/api/v1/__pycache__/async_scan.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/auth.cpython-311.pyc b/backend/app/api/v1/__pycache__/auth.cpython-311.pyc new file mode 100644 index 0000000..15c4386 Binary files /dev/null and b/backend/app/api/v1/__pycache__/auth.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/enhanced_scan.cpython-311.pyc b/backend/app/api/v1/__pycache__/enhanced_scan.cpython-311.pyc new file mode 100644 index 0000000..3a08ddf Binary files /dev/null and b/backend/app/api/v1/__pycache__/enhanced_scan.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/ips.cpython-311.pyc b/backend/app/api/v1/__pycache__/ips.cpython-311.pyc new file mode 100644 index 0000000..5e96667 Binary files /dev/null and b/backend/app/api/v1/__pycache__/ips.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/networks.cpython-311.pyc b/backend/app/api/v1/__pycache__/networks.cpython-311.pyc new file mode 100644 index 0000000..1c437cf Binary files /dev/null and b/backend/app/api/v1/__pycache__/networks.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/scan.cpython-311.pyc b/backend/app/api/v1/__pycache__/scan.cpython-311.pyc new file mode 100644 index 0000000..175c4a1 Binary files /dev/null and b/backend/app/api/v1/__pycache__/scan.cpython-311.pyc differ diff --git a/backend/app/api/v1/__pycache__/snmp.cpython-311.pyc b/backend/app/api/v1/__pycache__/snmp.cpython-311.pyc new file mode 100644 index 0000000..53aa76c Binary files /dev/null and b/backend/app/api/v1/__pycache__/snmp.cpython-311.pyc differ diff --git a/backend/app/api/v1/alerts.py b/backend/app/api/v1/alerts.py new file mode 100644 index 0000000..722639f --- /dev/null +++ b/backend/app/api/v1/alerts.py @@ -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 + } diff --git a/backend/app/api/v1/async_scan.py b/backend/app/api/v1/async_scan.py new file mode 100644 index 0000000..48fc496 --- /dev/null +++ b/backend/app/api/v1/async_scan.py @@ -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 可能未启动" + } diff --git a/backend/app/api/v1/audit.py b/backend/app/api/v1/audit.py new file mode 100644 index 0000000..7e31d0b --- /dev/null +++ b/backend/app/api/v1/audit.py @@ -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 \ No newline at end of file diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..5981318 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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": "用户已删除"} diff --git a/backend/app/api/v1/enhanced_scan.py b/backend/app/api/v1/enhanced_scan.py new file mode 100644 index 0000000..e3d6f81 --- /dev/null +++ b/backend/app/api/v1/enhanced_scan.py @@ -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 + } + } diff --git a/backend/app/api/v1/ips.py b/backend/app/api/v1/ips.py new file mode 100644 index 0000000..2c518eb --- /dev/null +++ b/backend/app/api/v1/ips.py @@ -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 \ No newline at end of file diff --git a/backend/app/api/v1/networks.py b/backend/app/api/v1/networks.py new file mode 100644 index 0000000..ef6a511 --- /dev/null +++ b/backend/app/api/v1/networks.py @@ -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": "删除成功"} \ No newline at end of file diff --git a/backend/app/api/v1/scan.py b/backend/app/api/v1/scan.py new file mode 100644 index 0000000..9dec1a3 --- /dev/null +++ b/backend/app/api/v1/scan.py @@ -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 + } diff --git a/backend/app/api/v1/snmp.py b/backend/app/api/v1/snmp.py new file mode 100644 index 0000000..d84d22a --- /dev/null +++ b/backend/app/api/v1/snmp.py @@ -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 + } + } \ No newline at end of file diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..f5a2b8b --- /dev/null +++ b/backend/app/core/__init__.py @@ -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"] diff --git a/backend/app/core/__pycache__/__init__.cpython-311.pyc b/backend/app/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..ca01d88 Binary files /dev/null and b/backend/app/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/config.cpython-311.pyc b/backend/app/core/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000..87ed076 Binary files /dev/null and b/backend/app/core/__pycache__/config.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/database.cpython-311.pyc b/backend/app/core/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000..637c46f Binary files /dev/null and b/backend/app/core/__pycache__/database.cpython-311.pyc differ diff --git a/backend/app/core/__pycache__/security.cpython-311.pyc b/backend/app/core/__pycache__/security.cpython-311.pyc new file mode 100644 index 0000000..2647878 Binary files /dev/null and b/backend/app/core/__pycache__/security.cpython-311.pyc differ diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..4297df3 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..fdda333 --- /dev/null +++ b/backend/app/core/database.py @@ -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() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..91db9c5 --- /dev/null +++ b/backend/app/core/security.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..48475e8 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__pycache__/alert.cpython-311.pyc b/backend/app/models/__pycache__/alert.cpython-311.pyc new file mode 100644 index 0000000..bfa4abf Binary files /dev/null and b/backend/app/models/__pycache__/alert.cpython-311.pyc differ diff --git a/backend/app/models/__pycache__/auth.cpython-311.pyc b/backend/app/models/__pycache__/auth.cpython-311.pyc new file mode 100644 index 0000000..3d6e167 Binary files /dev/null and b/backend/app/models/__pycache__/auth.cpython-311.pyc differ diff --git a/backend/app/models/__pycache__/network.cpython-311.pyc b/backend/app/models/__pycache__/network.cpython-311.pyc new file mode 100644 index 0000000..e1f73d8 Binary files /dev/null and b/backend/app/models/__pycache__/network.cpython-311.pyc differ diff --git a/backend/app/models/__pycache__/snmp.cpython-311.pyc b/backend/app/models/__pycache__/snmp.cpython-311.pyc new file mode 100644 index 0000000..e067f2b Binary files /dev/null and b/backend/app/models/__pycache__/snmp.cpython-311.pyc differ diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py new file mode 100644 index 0000000..0b2a5ce --- /dev/null +++ b/backend/app/models/alert.py @@ -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()) diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..08fc2b0 --- /dev/null +++ b/backend/app/models/audit.py @@ -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()) \ No newline at end of file diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py new file mode 100644 index 0000000..bcd2324 --- /dev/null +++ b/backend/app/models/auth.py @@ -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") diff --git a/backend/app/models/network.py b/backend/app/models/network.py new file mode 100644 index 0000000..9de3903 --- /dev/null +++ b/backend/app/models/network.py @@ -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") diff --git a/backend/app/models/snmp.py b/backend/app/models/snmp.py new file mode 100644 index 0000000..3b41656 --- /dev/null +++ b/backend/app/models/snmp.py @@ -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") diff --git a/backend/app/schemas/__pycache__/network.cpython-311.pyc b/backend/app/schemas/__pycache__/network.cpython-311.pyc new file mode 100644 index 0000000..6f4185d Binary files /dev/null and b/backend/app/schemas/__pycache__/network.cpython-311.pyc differ diff --git a/backend/app/schemas/network.py b/backend/app/schemas/network.py new file mode 100644 index 0000000..97d5726 --- /dev/null +++ b/backend/app/schemas/network.py @@ -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 diff --git a/backend/app/services/__pycache__/alert_service.cpython-311.pyc b/backend/app/services/__pycache__/alert_service.cpython-311.pyc new file mode 100644 index 0000000..b1fc973 Binary files /dev/null and b/backend/app/services/__pycache__/alert_service.cpython-311.pyc differ diff --git a/backend/app/services/__pycache__/enhanced_scan_service.cpython-311.pyc b/backend/app/services/__pycache__/enhanced_scan_service.cpython-311.pyc new file mode 100644 index 0000000..80de191 Binary files /dev/null and b/backend/app/services/__pycache__/enhanced_scan_service.cpython-311.pyc differ diff --git a/backend/app/services/__pycache__/ip_service.cpython-311.pyc b/backend/app/services/__pycache__/ip_service.cpython-311.pyc new file mode 100644 index 0000000..d4048a4 Binary files /dev/null and b/backend/app/services/__pycache__/ip_service.cpython-311.pyc differ diff --git a/backend/app/services/__pycache__/network_service.cpython-311.pyc b/backend/app/services/__pycache__/network_service.cpython-311.pyc new file mode 100644 index 0000000..f03e415 Binary files /dev/null and b/backend/app/services/__pycache__/network_service.cpython-311.pyc differ diff --git a/backend/app/services/__pycache__/scan_service.cpython-311.pyc b/backend/app/services/__pycache__/scan_service.cpython-311.pyc new file mode 100644 index 0000000..58e7fcd Binary files /dev/null and b/backend/app/services/__pycache__/scan_service.cpython-311.pyc differ diff --git a/backend/app/services/__pycache__/snmp_service.cpython-311.pyc b/backend/app/services/__pycache__/snmp_service.cpython-311.pyc new file mode 100644 index 0000000..a835278 Binary files /dev/null and b/backend/app/services/__pycache__/snmp_service.cpython-311.pyc differ diff --git a/backend/app/services/__pycache__/user_service.cpython-311.pyc b/backend/app/services/__pycache__/user_service.cpython-311.pyc new file mode 100644 index 0000000..be08c9a Binary files /dev/null and b/backend/app/services/__pycache__/user_service.cpython-311.pyc differ diff --git a/backend/app/services/alert_service.py b/backend/app/services/alert_service.py new file mode 100644 index 0000000..c65bb10 --- /dev/null +++ b/backend/app/services/alert_service.py @@ -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 diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..cfd31bf --- /dev/null +++ b/backend/app/services/audit_service.py @@ -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" \ No newline at end of file diff --git a/backend/app/services/enhanced_scan_service.py b/backend/app/services/enhanced_scan_service.py new file mode 100644 index 0000000..b5c1beb --- /dev/null +++ b/backend/app/services/enhanced_scan_service.py @@ -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 != '': + 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) + } diff --git a/backend/app/services/ip_service.py b/backend/app/services/ip_service.py new file mode 100644 index 0000000..44e7f69 --- /dev/null +++ b/backend/app/services/ip_service.py @@ -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() diff --git a/backend/app/services/network_service.py b/backend/app/services/network_service.py new file mode 100644 index 0000000..f72557c --- /dev/null +++ b/backend/app/services/network_service.py @@ -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 diff --git a/backend/app/services/scan_service.py b/backend/app/services/scan_service.py new file mode 100644 index 0000000..172e000 --- /dev/null +++ b/backend/app/services/scan_service.py @@ -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 diff --git a/backend/app/services/snmp_service.py b/backend/app/services/snmp_service.py new file mode 100644 index 0000000..fcebeb1 --- /dev/null +++ b/backend/app/services/snmp_service.py @@ -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 凭据缺少 username(V3 强制要求 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 diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..af1b0d9 --- /dev/null +++ b/backend/app/services/user_service.py @@ -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) diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 0000000..bb5940d --- /dev/null +++ b/backend/app/tasks/__init__.py @@ -0,0 +1,4 @@ +# app/tasks/__init__.py +from app.tasks.celery_app import celery_app + +__all__ = ['celery_app'] diff --git a/backend/app/tasks/__pycache__/__init__.cpython-311.pyc b/backend/app/tasks/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..b8fad61 Binary files /dev/null and b/backend/app/tasks/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/app/tasks/__pycache__/celery_app.cpython-311.pyc b/backend/app/tasks/__pycache__/celery_app.cpython-311.pyc new file mode 100644 index 0000000..6ede5e6 Binary files /dev/null and b/backend/app/tasks/__pycache__/celery_app.cpython-311.pyc differ diff --git a/backend/app/tasks/__pycache__/scan_tasks.cpython-311.pyc b/backend/app/tasks/__pycache__/scan_tasks.cpython-311.pyc new file mode 100644 index 0000000..b636b7c Binary files /dev/null and b/backend/app/tasks/__pycache__/scan_tasks.cpython-311.pyc differ diff --git a/backend/app/tasks/celery_app.py b/backend/app/tasks/celery_app.py new file mode 100644 index 0000000..c0e7350 --- /dev/null +++ b/backend/app/tasks/celery_app.py @@ -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分钟 + }, +} diff --git a/backend/app/tasks/scan_tasks.py b/backend/app/tasks/scan_tasks.py new file mode 100644 index 0000000..9dd34cb --- /dev/null +++ b/backend/app/tasks/scan_tasks.py @@ -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 + } diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ed45633 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..da4deb0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,103 @@ +version: '3.8' + +services: + # MySQL 数据库 + mysql: + image: mysql:8.0 + container_name: ipam-mysql + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-ipam2024 + MYSQL_DATABASE: ${MYSQL_DATABASE:-ipam} + MYSQL_USER: ${MYSQL_USER:-ipam} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-ipam2024} + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + command: --default-authentication-plugin=mysql_native_password + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 10 + + # Redis 缓存 & Celery Broker + redis: + image: redis:7-alpine + container_name: ipam-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # FastAPI 后端服务 + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: ipam-backend + ports: + - "8000:8000" + environment: + - DATABASE_URL=mysql+pymysql://${MYSQL_USER:-ipam}:${MYSQL_PASSWORD:-ipam2024}@mysql:3306/${MYSQL_DATABASE:-ipam} + - REDIS_URL=redis://redis:6379/0 + - CELERY_BROKER_URL=redis://redis:6379/1 + - CELERY_RESULT_BACKEND=redis://redis:6379/2 + volumes: + - ./backend:/app + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + + # Celery Worker - 扫描任务执行 + celery-worker: + build: + context: ./backend + dockerfile: Dockerfile + container_name: ipam-celery-worker + command: celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4 + environment: + - DATABASE_URL=mysql+pymysql://${MYSQL_USER:-ipam}:${MYSQL_PASSWORD:-ipam2024}@mysql:3306/${MYSQL_DATABASE:-ipam} + - REDIS_URL=redis://redis:6379/0 + - CELERY_BROKER_URL=redis://redis:6379/1 + - CELERY_RESULT_BACKEND=redis://redis:6379/2 + volumes: + - ./backend:/app + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + + # Celery Beat - 定时任务调度 + celery-beat: + build: + context: ./backend + dockerfile: Dockerfile + container_name: ipam-celery-beat + command: celery -A app.tasks.celery_app beat --loglevel=info + environment: + - DATABASE_URL=mysql+pymysql://${MYSQL_USER:-ipam}:${MYSQL_PASSWORD:-ipam2024}@mysql:3306/${MYSQL_DATABASE:-ipam} + - REDIS_URL=redis://redis:6379/0 + - CELERY_BROKER_URL=redis://redis:6379/1 + - CELERY_RESULT_BACKEND=redis://redis:6379/2 + volumes: + - ./backend:/app + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + +volumes: + mysql_data: + redis_data: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..dd48848 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,260 @@ +# IPAM 管理系统 - 开发文档 + +## 项目概述 + +IPAM (IP Address Management) 是一个企业级IP地址管理系统,用于自动化扫描、发现和管理网络IP资产。 + +## 需求优先级划分 + +### P0 - 核心功能(必须优先实现) + +#### 1. 项目基础架构 +- [ ] FastAPI 后端框架搭建 +- [ ] MySQL 数据库模型设计 +- [ ] Redis 缓存配置 +- [ ] Celery 异步任务框架 +- [ ] Docker & Docker Compose 容器化部署 + +#### 2. 网络与子网管理 +- [ ] 网段CRUD(支持CIDR格式) +- [ ] 网段分组管理 +- [ ] 网段统计视图(总IP、已用、空闲、使用率) + +#### 3. IP资产台账 +- [ ] IP地址实体模型 +- [ ] 基础字段:IP、MAC、主机名 +- [ ] IP状态管理(在线、离线、空闲、保留) +- [ ] 自定义字段扩展框架 + +#### 4. 基础扫描引擎 +- [ ] ICMP Ping扫描 +- [ ] 手动触发扫描 + +### P1 - 重要功能(核心功能完成后实现) + +#### 1. 高级扫描功能 +- [ ] ARP扫描获取MAC地址 +- [ ] 反向DNS解析主机名 +- [ ] 定时扫描任务调度 + +#### 2. SNMP设备集成 +- [ ] SNMP凭据池(v2c + v3) +- [ ] 网络设备台账 +- [ ] SNMP连通性测试 +- [ ] ARP表拉取(IP-MAC映射) +- [ ] MAC地址表拉取(MAC-端口映射) + +#### 3. 告警与异常检测 +- [ ] IP冲突告警 +- [ ] 非授权接入告警 +- [ ] 网段耗尽预警 + +### P2 - 增强功能 + +#### 1. 厂商深度发现 +- [ ] Cisco/H3C私有MIB支持 +- [ ] LLDP/CDP邻居发现 +- [ ] 物理拓扑解析 +- [ ] OUI厂商识别 + +#### 2. 系统管理 +- [ ] 操作审计日志 +- [ ] IP历史轨迹 +- [ ] 角色权限控制 + +#### 3. 前端界面 +- [ ] Vue 3 + Element Plus +- [ ] 网段大盘视图 +- [ ] IP台账表格 +- [ ] 网络拓扑展示 + +## 技术栈 + +### 后端 +- **Web框架**: FastAPI 0.110+ +- **数据库**: MySQL 8.0 +- **ORM**: SQLAlchemy 2.0 +- **缓存**: Redis 7.0 +- **异步任务**: Celery 5.3+ +- **SNMP**: pysnmp +- **网络探测**: Scapy +- **数据验证**: Pydantic 2.0 + +### 前端 +- **框架**: Vue 3 +- **UI组件**: Element Plus +- **状态管理**: Pinia +- **路由**: Vue Router 4 + +### 部署 +- **容器化**: Docker + Docker Compose +- **反向代理**: Nginx + +## 项目结构 + +``` +ipam/ +├── backend/ # 后端代码 +│ ├── app/ +│ │ ├── api/ # API路由 +│ │ │ ├── v1/ +│ │ │ │ ├── networks.py +│ │ │ │ ├── ips.py +│ │ │ │ ├── scan.py +│ │ │ │ └── devices.py +│ │ │ └── dependencies.py +│ │ ├── core/ # 核心配置 +│ │ │ ├── config.py +│ │ │ ├── database.py +│ │ │ └── security.py +│ │ ├── models/ # 数据库模型 +│ │ │ ├── network.py +│ │ │ ├── ip.py +│ │ │ ├── device.py +│ │ │ └── alert.py +│ │ ├── schemas/ # Pydantic模型 +│ │ │ ├── network.py +│ │ │ ├── ip.py +│ │ │ └── scan.py +│ │ ├── services/ # 业务逻辑 +│ │ │ ├── network_service.py +│ │ │ ├── scan_service.py +│ │ │ └── snmp_service.py +│ │ ├── tasks/ # Celery任务 +│ │ │ ├── celery_app.py +│ │ │ ├── scan_tasks.py +│ │ │ └── snmp_tasks.py +│ │ └── main.py +│ ├── requirements.txt +│ ├── Dockerfile +│ └── .env.example +├── frontend/ # 前端代码 +│ ├── src/ +│ ├── package.json +│ └── Dockerfile +├── docker-compose.yml +├── docs/ # 文档 +│ └── DEVELOPMENT.md +└── README.md +``` + +## 数据库设计 + +### 核心表结构 + +#### networks(网段表) +```sql +id INT PRIMARY KEY +cidr VARCHAR(50) UNIQUE -- 如: 192.168.1.0/24 +name VARCHAR(100) +description TEXT +group_name VARCHAR(100) -- 分组名称 +gateway VARCHAR(50) +vlan_id INT +total_ips INT +used_ips INT DEFAULT 0 +reserved_ips INT DEFAULT 0 +created_at DATETIME +updated_at DATETIME +``` + +#### ip_addresses(IP地址表) +```sql +id INT PRIMARY KEY +network_id INT FOREIGN KEY +ip_address VARCHAR(50) UNIQUE +mac_address VARCHAR(50) NULL +hostname VARCHAR(255) NULL +status ENUM('available', 'online', 'offline', 'reserved') +owner VARCHAR(100) NULL +business_type VARCHAR(50) NULL +notes TEXT NULL +custom_fields JSON -- 自定义字段 +last_seen DATETIME NULL +created_at DATETIME +updated_at DATETIME +``` + +#### scan_tasks(扫描任务表) +```sql +id INT PRIMARY KEY +network_id INT NULL +task_type ENUM('ping', 'arp', 'snmp') +status ENUM('pending', 'running', 'completed', 'failed') +progress INT DEFAULT 0 +started_at DATETIME NULL +completed_at DATETIME NULL +result TEXT NULL +created_at DATETIME +``` + +#### snmp_credentials(SNMP凭据表) +```sql +id INT PRIMARY KEY +name VARCHAR(100) +version ENUM('v2c', 'v3') +community_string VARCHAR(255) NULL -- v2c +username VARCHAR(100) NULL -- v3 +auth_password VARCHAR(255) NULL -- v3 +auth_protocol VARCHAR(50) NULL -- MD5, SHA +priv_password VARCHAR(255) NULL -- v3 +priv_protocol VARCHAR(50) NULL -- DES, AES +created_at DATETIME +``` + +#### network_devices(网络设备表) +```sql +id INT PRIMARY KEY +name VARCHAR(100) +ip_address VARCHAR(50) +device_type VARCHAR(50) -- core, distribution, access +vendor VARCHAR(100) NULL +model VARCHAR(100) NULL +snmp_credential_id INT FOREIGN KEY +last_polled_at DATETIME NULL +created_at DATETIME +``` + +#### alerts(告警表) +```sql +id INT PRIMARY KEY +alert_type ENUM('ip_conflict', 'unauthorized', 'subnet_full') +severity ENUM('warning', 'error', 'critical') +message TEXT +resolved BOOLEAN DEFAULT FALSE +resolved_at DATETIME NULL +created_at DATETIME +``` + +## API 接口设计 + +### 网段管理 +- `GET /api/v1/networks` - 获取网段列表 +- `POST /api/v1/networks` - 创建网段 +- `GET /api/v1/networks/{id}` - 获取网段详情 +- `PUT /api/v1/networks/{id}` - 更新网段 +- `DELETE /api/v1/networks/{id}` - 删除网段 +- `GET /api/v1/networks/{id}/stats` - 获取网段统计数据 + +### IP地址管理 +- `GET /api/v1/ips` - 获取IP列表 +- `GET /api/v1/ips/{id}` - 获取IP详情 +- `PUT /api/v1/ips/{id}` - 更新IP信息 +- `GET /api/v1/ips/export` - 导出IP台账 + +### 扫描管理 +- `POST /api/v1/scan/ping/{network_id}` - 触发Ping扫描 +- `GET /api/v1/scan/tasks/{task_id}` - 获取扫描任务状态 + +## 开发规范 + +1. **代码风格**: 遵循 PEP 8,使用 black 格式化 +2. **类型注解**: 使用 Python 类型注解 +3. **数据库迁移**: 使用 Alembic 管理数据库迁移 +4. **API文档**: FastAPI 自动生成 Swagger 文档 +5. **日志**: 使用 logging 模块记录关键操作 +6. **测试**: 每个模块完成后编写单元测试 + +## 部署说明 + +详见 `docker-compose.yml` 配置文件。 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..fca22a4 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1839 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.18.1", + "element-plus": "^2.14.3", + "pinia": "^4.0.2", + "vue": "^3.5.39", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "vite": "^5.4.21" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz", + "integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-kit": "^8.1.5" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz", + "integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-shared": "^8.1.5", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz", + "integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.3", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz", + "integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.5" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT", + "peer": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/nostics": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.1.4.tgz", + "integrity": "sha512-U4FApICSLCQ0dYiN59pxUCEZC053palQXi57yLaNdMaL6TBY4C4q3qZrJKUGcor2dGv8EhEwt8y5KvU2bo2kFg==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT", + "peer": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-4.0.2.tgz", + "integrity": "sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==", + "license": "MIT", + "dependencies": { + "nostics": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/devtools-api": "^8.1.5", + "typescript": ">=5.6.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "@vue/devtools-api": { + "optional": false + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.7.tgz", + "integrity": "sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..65f3f96 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.18.1", + "element-plus": "^2.14.3", + "pinia": "^4.0.2", + "vue": "^3.5.39", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "vite": "^5.4.21" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..6a8c0b2 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..923c1ba --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,82 @@ +import api from '@/utils/api' +import { authStore } from '@/utils/auth' + +// 登录用 form-data(OAuth2PasswordRequestForm 期望 form-data) +function login({ username, password }) { + const formBody = new URLSearchParams() + formBody.append('username', username) + formBody.append('password', password) + formBody.append('grant_type', 'password') + return api.post('/auth/login', formBody, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + // 登录请求不能带 Authorization header(utils/api.js 里在已有 token 时会带) + // 但登录接口允许带,下游忽略即可 + }) +} + +async function loginAndStore({ username, password }) { + const data = await login({ username, password }) + if (data?.access_token) { + authStore.setAccessToken(data.access_token) + authStore.setRefreshToken(data.refresh_token) + authStore.setUserInfo(data.user_info) + } + return data +} + +function logout() { + const refreshToken = authStore.getRefreshToken() + authStore.clear() + if (refreshToken) { + // 后端撤销 refresh token(best-effort,不 await) + try { + const formBody = new URLSearchParams() + formBody.append('refresh_token', refreshToken) + api.post('/auth/logout', formBody, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }).catch(() => {}) + } catch (_) { /* noop */ } + } +} + +function fetchCurrentUser() { + return api.get('/auth/me').then(info => { + authStore.setUserInfo(info) + return info + }) +} + +function changePassword(oldPassword, newPassword) { + return api.post('/auth/change-password', null, { + params: { old_password: oldPassword, new_password: newPassword } + }) +} + +// 用户管理(admin 权限) +// 注意:后端 endpoint 使用 query 参数接收(不是 JSON body), +// 与其他 CRUD 接口的 POST + JSON body 风格不一致,但保持后端原样 +const userApi = { + getList(params) { + return api.get('/auth/users', { params }) + }, + getById(id) { + return api.get(`/auth/users/${id}`) + }, + create(data) { + return api.post('/auth/users', null, { params: data }) + }, + updateStatus(id, status) { + return api.put(`/auth/users/${id}/status`, null, { params: { status } }) + }, + unlock(id) { + return api.post(`/auth/users/${id}/unlock`) + }, + resetPassword(id, newPassword) { + return api.post(`/auth/users/${id}/reset-password`, null, { params: { new_password: newPassword } }) + }, + delete(id) { + return api.delete(`/auth/users/${id}`) + }, +} + +export { login, loginAndStore, logout, fetchCurrentUser, changePassword, userApi } \ No newline at end of file diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..97c4944 --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,176 @@ +import api from '@/utils/api' + +// 网段管理 +export const networkApi = { + // 获取网段列表 + getList(params) { + return api.get('/networks', { params }) + }, + // 获取网段详情 + getById(id) { + return api.get(`/networks/${id}`) + }, + // 获取网段统计 + getStats(id) { + return api.get(`/networks/${id}/stats`) + }, + // 获取所有网段统计 + getAllStats() { + return api.get('/networks/stats') + }, + // 创建网段 + create(data) { + return api.post('/networks', data) + }, + // 更新网段 + update(id, data) { + return api.put(`/networks/${id}`, data) + }, + // 删除网段 + delete(id) { + return api.delete(`/networks/${id}`) + }, + // 获取网段下的IP + getIPs(id, params) { + return api.get(`/networks/${id}/ips`, { params }) + } +} + +// IP地址管理 +export const ipApi = { + // 获取IP列表 + getList(params) { + return api.get('/ips', { params }) + }, + // 获取IP详情 + getById(id) { + return api.get(`/ips/${id}`) + }, + // 更新IP信息 + update(id, data) { + return api.put(`/ips/${id}`, data) + }, + // 根据IP地址查询 + getByIP(ip) { + return api.get(`/ips/address/${ip}`) + } +} + +// 扫描管理 +export const scanApi = { + // Ping扫描网段 + pingNetwork(id) { + return api.post(`/scan/ping/${id}`) + }, + // 综合扫描网段 + // options: { enable_ping, enable_arp, enable_dns, timeout } + // 单次调用覆盖全局 30s 超时,因为 /24 网段扫描要数分钟 + comprehensiveScan(id, options = {}) { + const { + enable_ping = true, + enable_arp = true, + enable_dns = true, + timeout = 2 + } = options + return api.post(`/scan/comprehensive/${id}`, {}, { + params: { enable_ping, enable_arp, enable_dns, timeout }, + timeout: 600000 // 10 分钟,足以应付 /16 级别网段 + }) + }, + // 单个IP扫描 + scanIP(ipAddress) { + return api.post(`/scan/comprehensive/ip/${ipAddress}`) + }, + // 获取统计摘要 + getSummary() { + return api.get('/scan/statistics/summary') + } +} + +// 告警管理 +export const alertApi = { + // 获取告警列表 + getList(params) { + return api.get('/alerts', { params }) + }, + // 获取告警统计 + getStats() { + return api.get('/alerts/statistics/summary') + }, + // 运行所有检测 + runDetection() { + return api.post('/alerts/detect/run') + }, + // 确认告警 + acknowledge(id) { + return api.post(`/alerts/${id}/acknowledge`) + }, + // 解决告警 + resolve(id, notes) { + return api.post(`/alerts/${id}/resolve`, {}, { params: { notes } }) + }, + // 忽略告警 + ignore(id) { + return api.post(`/alerts/${id}/ignore`) + }, + // MAC白名单 + getWhitelist(params) { + return api.get('/alerts/whitelist/macs', { params }) + }, + addToWhitelist(data) { + return api.post('/alerts/whitelist/macs', {}, { params: data }) + }, + removeFromWhitelist(id) { + return api.delete(`/alerts/whitelist/macs/${id}`) + } +} + +// SNMP管理 +export const snmpApi = { + // 凭据管理 + getCredentials() { + return api.get('/snmp/credentials') + }, + createCredential(data) { + return api.post('/snmp/credentials', {}, { params: data }) + }, + updateCredential(id, data) { + return api.put(`/snmp/credentials/${id}`, {}, { params: data }) + }, + deleteCredential(id) { + return api.delete(`/snmp/credentials/${id}`) + }, + + // 设备管理 + getDevices(params) { + return api.get('/snmp/devices', { params }) + }, + createDevice(data) { + return api.post('/snmp/devices', {}, { params: data }) + }, + updateDevice(id, data) { + return api.put(`/snmp/devices/${id}`, {}, { params: data }) + }, + deleteDevice(id) { + return api.delete(`/snmp/devices/${id}`) + }, + + // SNMP操作 + testConnection(id) { + return api.post(`/snmp/devices/${id}/test`) + }, + pollDevice(id) { + return api.post(`/snmp/devices/${id}/poll`) + }, + getARPTable(id) { + return api.post(`/snmp/devices/${id}/arp`) + }, + getInterfaces(id) { + return api.post(`/snmp/devices/${id}/interfaces`) + }, + + // 获取统计 + getStats() { + return api.get('/snmp/statistics/summary') + } +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..f91553d --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/layout/MainLayout.vue b/frontend/src/layout/MainLayout.vue new file mode 100644 index 0000000..2841c8f --- /dev/null +++ b/frontend/src/layout/MainLayout.vue @@ -0,0 +1,301 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..230b9d0 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,22 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' + +import App from './App.vue' +import router from './router' + +const app = createApp(App) +const pinia = createPinia() + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(pinia) +app.use(ElementPlus) +app.use(router) + +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..b90982b --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,110 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { authStore } from '@/utils/auth' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { title: '登录', public: true } + }, + { + path: '/', + redirect: '/dashboard' + }, + { + path: '/', + component: () => import('@/layout/MainLayout.vue'), + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { title: '仪表盘' } + }, + { + path: 'networks', + name: 'Networks', + component: () => import('@/views/Networks.vue'), + meta: { title: '网段管理' } + }, + { + path: 'ips', + name: 'IPs', + component: () => import('@/views/IPs.vue'), + meta: { title: 'IP地址' } + }, + { + path: 'scan', + name: 'Scan', + component: () => import('@/views/Scan.vue'), + meta: { title: '扫描管理' } + }, + { + path: 'snmp', + name: 'SNMP', + component: () => import('@/views/SNMP.vue'), + meta: { title: 'SNMP管理' } + }, + { + path: 'alerts', + name: 'Alerts', + component: () => import('@/views/Alerts.vue'), + meta: { title: '告警中心' } + }, + { + path: 'users', + name: 'Users', + component: () => import('@/views/Users.vue'), + meta: { title: '用户管理', requiresAdmin: true } + }, + { + path: 'profile', + name: 'Profile', + component: () => import('@/views/Profile.vue'), + meta: { title: '个人中心' } + } + ] + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/Login.vue'), + meta: { public: true } + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 全局路由守卫 +router.beforeEach((to, from, next) => { + // 公开路由(登录页)直接放行 + if (to.meta?.public) { + next() + return + } + // 未登录跳登录 + if (!authStore.isLoggedIn()) { + next({ path: '/login', query: { next: to.fullPath } }) + return + } + // 需要管理员 + if (to.meta?.requiresAdmin && !authStore.hasRole('super_admin', 'admin')) { + next({ path: '/dashboard' }) + return + } + next() +}) + +router.afterEach((to) => { + if (to.meta?.title) { + document.title = `${to.meta.title} - IPAM管理系统` + } else { + document.title = 'IPAM管理系统' + } +}) + +export default router \ No newline at end of file diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..527d4fb --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,296 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} + +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#app { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js new file mode 100644 index 0000000..46d70f5 --- /dev/null +++ b/frontend/src/utils/api.js @@ -0,0 +1,118 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import { authStore } from '@/utils/auth' + +const api = axios.create({ + baseURL: '/api/v1', + timeout: 30000, + headers: { + 'Content-Type': 'application/json' + } +}) + +// 请求拦截器:自动带 Authorization header +api.interceptors.request.use( + config => { + const token = authStore.getAccessToken() + if (token) { + config.headers = config.headers || {} + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + error => Promise.reject(error) +) + +// 标记正在刷新 token,防止并发 401 触发多次刷新 +let isRefreshing = false +let pendingQueue = [] + +function flushQueue(newToken) { + pendingQueue.forEach(cb => cb(newToken)) + pendingQueue = [] +} + +function queueRefresh(cb) { + pendingQueue.push(cb) +} + +// 响应拦截器:401 时尝试 refresh token;refresh 失败就跳登录 +api.interceptors.response.use( + response => { + return response.data + }, + async error => { + const originalRequest = error.config || {} + const status = error.response?.status + const detail = error.response?.data?.detail + + // 401 且不是 login/refresh 本身:尝试刷新 token + if (status === 401 && !originalRequest._retry && !originalRequest.url?.includes('/auth/')) { + const refreshToken = authStore.getRefreshToken() + if (!refreshToken) { + authStore.clear() + redirectToLogin() + return Promise.reject(error) + } + + if (isRefreshing) { + // 已有刷新请求在进行,等它完成后再重试 + return new Promise(resolve => { + queueRefresh(newToken => { + if (newToken) { + originalRequest._retry = true + originalRequest.headers.Authorization = `Bearer ${newToken}` + resolve(api(originalRequest)) + } else { + redirectToLogin() + resolve(Promise.reject(error)) + } + }) + }) + } + + originalRequest._retry = true + isRefreshing = true + try { + const formBody = new URLSearchParams() + formBody.append('refresh_token', refreshToken) + const resp = await axios.post('/api/v1/auth/refresh', formBody, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }) + const newToken = resp.data?.access_token + if (newToken) { + authStore.setAccessToken(newToken) + if (resp.data?.refresh_token) { + authStore.setRefreshToken(resp.data.refresh_token) + } + isRefreshing = false + flushQueue(newToken) + originalRequest.headers.Authorization = `Bearer ${newToken}` + return api(originalRequest) + } else { + throw new Error('refresh 响应缺少 access_token') + } + } catch (refreshErr) { + isRefreshing = false + flushQueue(null) + authStore.clear() + redirectToLogin() + return Promise.reject(refreshErr) + } + } + + // 非 401 或 refresh 后仍失败的:弹 toast(保持原行为,避免 view 重复弹) + console.error('API Error:', error) + ElMessage.error(typeof detail === 'string' ? detail : '请求失败') + return Promise.reject(error) + } +) + +function redirectToLogin() { + if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + const next = encodeURIComponent(window.location.pathname + window.location.search) + window.location.href = `/login?next=${next}` + } +} + +export default api \ No newline at end of file diff --git a/frontend/src/utils/auth.js b/frontend/src/utils/auth.js new file mode 100644 index 0000000..2001ed0 --- /dev/null +++ b/frontend/src/utils/auth.js @@ -0,0 +1,56 @@ +// token / 用户信息本地存储工具 +const ACCESS_TOKEN_KEY = 'ipam_access_token' +const REFRESH_TOKEN_KEY = 'ipam_refresh_token' +const USER_INFO_KEY = 'ipam_user_info' + +export const authStore = { + getAccessToken() { + return localStorage.getItem(ACCESS_TOKEN_KEY) || '' + }, + setAccessToken(token) { + if (token) { + localStorage.setItem(ACCESS_TOKEN_KEY, token) + } else { + localStorage.removeItem(ACCESS_TOKEN_KEY) + } + }, + getRefreshToken() { + return localStorage.getItem(REFRESH_TOKEN_KEY) || '' + }, + setRefreshToken(token) { + if (token) { + localStorage.setItem(REFRESH_TOKEN_KEY, token) + } else { + localStorage.removeItem(REFRESH_TOKEN_KEY) + } + }, + getUserInfo() { + const raw = localStorage.getItem(USER_INFO_KEY) + if (!raw) return null + try { + return JSON.parse(raw) + } catch { + return null + } + }, + setUserInfo(info) { + if (info) { + localStorage.setItem(USER_INFO_KEY, JSON.stringify(info)) + } else { + localStorage.removeItem(USER_INFO_KEY) + } + }, + clear() { + localStorage.removeItem(ACCESS_TOKEN_KEY) + localStorage.removeItem(REFRESH_TOKEN_KEY) + localStorage.removeItem(USER_INFO_KEY) + }, + isLoggedIn() { + return !!this.getAccessToken() + }, + // 基于 role 判断(仅前端展示用,真实校验在后端) + hasRole(...roles) { + const u = this.getUserInfo() + return u && roles.includes(u.role) + }, +} \ No newline at end of file diff --git a/frontend/src/views/Alerts.vue b/frontend/src/views/Alerts.vue new file mode 100644 index 0000000..1072775 --- /dev/null +++ b/frontend/src/views/Alerts.vue @@ -0,0 +1,575 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..259fe48 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/frontend/src/views/IPs.vue b/frontend/src/views/IPs.vue new file mode 100644 index 0000000..38f3a58 --- /dev/null +++ b/frontend/src/views/IPs.vue @@ -0,0 +1,372 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..cbf36ef --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,141 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Networks.vue b/frontend/src/views/Networks.vue new file mode 100644 index 0000000..f3af107 --- /dev/null +++ b/frontend/src/views/Networks.vue @@ -0,0 +1,333 @@ + + + + + diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue new file mode 100644 index 0000000..242a707 --- /dev/null +++ b/frontend/src/views/Profile.vue @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/SNMP.vue b/frontend/src/views/SNMP.vue new file mode 100644 index 0000000..fabfd77 --- /dev/null +++ b/frontend/src/views/SNMP.vue @@ -0,0 +1,563 @@ + + + + + diff --git a/frontend/src/views/Scan.vue b/frontend/src/views/Scan.vue new file mode 100644 index 0000000..f9097b6 --- /dev/null +++ b/frontend/src/views/Scan.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue new file mode 100644 index 0000000..70c9aef --- /dev/null +++ b/frontend/src/views/Users.vue @@ -0,0 +1,363 @@ + + + + + \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..34ad7c7 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,26 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + host: '0.0.0.0', + port: 3000, + // 允许任意 Host 头访问(HMR WebSocket 在远程访问时需要) + allowedHosts: true, + proxy: { + '/api': { + target: 'http://localhost:8008', + changeOrigin: true + } + } + } +}) diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..95cea5f --- /dev/null +++ b/start.sh @@ -0,0 +1,227 @@ +#!/bin/bash +# IPAM 管理系统一键启动脚本 + +echo "==========================================" +echo " IPAM 管理系统启动脚本" +echo "==========================================" +echo "" + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 项目根目录 +BASE_DIR="/root/ipam" +BACKEND_DIR="$BASE_DIR/backend" +FRONTEND_DIR="$BASE_DIR/frontend" + +# 端口配置 +API_PORT=8008 +FRONTEND_PORT=3000 + +# 检查 Docker 容器状态 +check_docker_containers() { + echo "🔍 检查 Docker 容器..." + + # 检查 MySQL + if [ "$(docker inspect -f '{{.State.Running}}' ipam-mysql 2>/dev/null)" != "true" ]; then + echo -e "${YELLOW}MySQL 容器未运行,正在启动...${NC}" + docker start ipam-mysql 2>/dev/null || { + echo -e "${YELLOW}创建 MySQL 容器...${NC}" + docker run -d --name ipam-mysql -p 3308:3306 \ + -e MYSQL_ROOT_PASSWORD=*** \ + -e MYSQL_DATABASE=ipam \ + -e MYSQL_USER=ipam \ + -e MYSQL_PASSWORD=*** \ + mysql:8.0 --default-authentication-plugin=mysql_native_password + } + fi + echo -e "${GREEN}✅ MySQL 运行正常${NC}" + + # 检查 Redis + if [ "$(docker inspect -f '{{.State.Running}}' ipam-redis 2>/dev/null)" != "true" ]; then + echo -e "${YELLOW}Redis 容器未运行,正在启动...${NC}" + docker start ipam-redis 2>/dev/null || { + echo -e "${YELLOW}创建 Redis 容器...${NC}" + docker run -d --name ipam-redis -p 6379:6379 redis:7-alpine + } + fi + echo -e "${GREEN}✅ Redis 运行正常${NC}" + echo "" +} + +# 检查并杀死已占用的端口 +kill_port() { + local port=$1 + local pid=$(lsof -t -i:$port 2>/dev/null || netstat -tlnp 2>/dev/null | grep ":$port" | awk '{print $7}' | cut -d'/' -f1) + if [ -n "$pid" ]; then + echo -e "${YELLOW}端口 $port 被占用 (PID $pid),正在关闭...${NC}" + kill -9 $pid 2>/dev/null + sleep 2 + fi +} + +# 启动后端 API +start_backend() { + echo "🚀 启动后端 API 服务..." + + # 检查虚拟环境 + if [ ! -d "$BACKEND_DIR/venv" ]; then + echo -e "${YELLOW}创建 Python 虚拟环境...${NC}" + cd $BACKEND_DIR + python3 -m venv venv + fi + + # 激活虚拟环境并启动 + cd $BACKEND_DIR + source venv/bin/activate + + # 检查端口并清理 + kill_port $API_PORT + + # 后台启动 uvicorn + nohup uvicorn app.main:app --host 0.0.0.0 --port $API_PORT > /tmp/ipam-backend.log 2>&1 & + BACKEND_PID=$! + + # 等待服务启动 + sleep 5 + + # 检查是否启动成功 + if curl -s http://localhost:$API_PORT/health > /dev/null; then + echo -e "${GREEN}✅ 后端 API 启动成功 (端口 $API_PORT)${NC}" + echo " PID: $BACKEND_PID" + echo " 日志: /tmp/ipam-backend.log" + return 0 + else + echo -e "${RED}❌ 后端 API 启动失败${NC}" + echo " 查看日志: tail -50 /tmp/ipam-backend.log" + return 1 + fi + echo "" +} + +# 启动 Celery Worker +start_celery() { + echo "🚀 启动 Celery Worker..." + + cd $BACKEND_DIR + source venv/bin/activate + + # 检查是否已有 celery 进程 + pkill -f "celery.*app.tasks.celery_app" 2>/dev/null + sleep 1 + + # 后台启动 celery + nohup celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4 > /tmp/ipam-celery.log 2>&1 & + CELERY_PID=$! + + sleep 3 + + if ps -p $CELERY_PID > /dev/null; then + echo -e "${GREEN}✅ Celery Worker 启动成功${NC}" + echo " PID: $CELERY_PID" + echo " 日志: /tmp/ipam-celery.log" + return 0 + else + echo -e "${RED}❌ Celery Worker 启动失败${NC}" + echo " 查看日志: tail -50 /tmp/ipam-celery.log" + return 1 + fi + echo "" +} + +# 启动前端 +start_frontend() { + echo "🚀 启动前端服务..." + + cd $FRONTEND_DIR + + # 检查 node_modules + if [ ! -d "node_modules" ]; then + echo -e "${YELLOW}安装前端依赖...${NC}" + npm install + fi + + # 检查端口并清理 + kill_port $FRONTEND_PORT + + # 后台启动前端 + nohup npm run dev -- --host 0.0.0.0 --port $FRONTEND_PORT > /tmp/ipam-frontend.log 2>&1 & + FRONTEND_PID=$! + + sleep 8 + + if curl -s http://localhost:$FRONTEND_PORT > /dev/null; then + echo -e "${GREEN}✅ 前端服务启动成功 (端口 $FRONTEND_PORT)${NC}" + echo " PID: $FRONTEND_PID" + echo " 日志: /tmp/ipam-frontend.log" + return 0 + else + echo -e "${RED}❌ 前端服务启动失败${NC}" + echo " 查看日志: tail -50 /tmp/ipam-frontend.log" + return 1 + fi + echo "" +} + +# 显示服务状态 +show_status() { + echo "" + echo "==========================================" + echo " 🎉 IPAM 系统启动完成!" + echo "==========================================" + echo "" + echo "📊 服务状态:" + echo " - MySQL: 127.0.0.1:3308 ✅" + echo " - Redis: 127.0.0.1:6379 ✅" + echo " - 后端 API: http://$(hostname -I | awk '{print $1}'):$API_PORT ✅" + echo " - Celery: 后台运行 ✅" + echo " - 前端界面: http://$(hostname -I | awk '{print $1}'):$FRONTEND_PORT ✅" + echo "" + echo "📖 访问地址:" + echo " - 管理界面: http://$(hostname -I | awk '{print $1}'):$FRONTEND_PORT" + echo " - API 文档: http://$(hostname -I | awk '{print $1}'):$API_PORT/docs" + echo "" + echo "🛑 停止服务命令:" + echo " pkill -f 'uvicorn|celery|vite'" + echo "" +} + +# 主程序 +main() { + # 检查是否是 root 用户 + if [ "$EUID" -ne 0 ]; then + echo -e "${RED}请使用 root 权限运行此脚本${NC}" + exit 1 + fi + + # 检查 Docker 是否安装 + if ! command -v docker &> /dev/null; then + echo -e "${RED}Docker 未安装,请先安装 Docker${NC}" + exit 1 + fi + + # 检查 Python + if ! command -v python3 &> /dev/null; then + echo -e "${RED}Python3 未安装${NC}" + exit 1 + fi + + # 检查 Node.js + if ! command -v node &> /dev/null; then + echo -e "${RED}Node.js 未安装${NC}" + exit 1 + fi + + echo "" + check_docker_containers + start_backend + start_celery + start_frontend + show_status +} + +# 运行主程序 +main diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..f04fecc --- /dev/null +++ b/stop.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# IPAM 管理系统停止脚本 + +echo "==========================================" +echo " IPAM 管理系统停止脚本" +echo "==========================================" +echo "" + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "🛑 正在停止所有服务..." +echo "" + +# 停止后端 +echo "停止后端 API 服务..." +pkill -f "uvicorn app.main:app" 2>/dev/null +if [ $? -eq 0 ]; then + echo -e "${GREEN}✅ 后端已停止${NC}" +else + echo -e "${YELLOW}ℹ️ 后端未运行${NC}" +fi + +# 停止 Celery +echo "" +echo "停止 Celery Worker..." +pkill -f "celery.*app.tasks.celery_app" 2>/dev/null +if [ $? -eq 0 ]; then + echo -e "${GREEN}✅ Celery Worker 已停止${NC}" +else + echo -e "${YELLOW}ℹ️ Celery Worker 未运行${NC}" +fi + +# 停止前端 +echo "" +echo "停止前端服务..." +pkill -f "vite" 2>/dev/null +if [ $? -eq 0 ]; then + echo -e "${GREEN}✅ 前端服务已停止${NC}" +else + echo -e "${YELLOW}ℹ️ 前端服务未运行${NC}" +fi + +echo "" +echo "==========================================" +echo " 所有服务已停止" +echo "==========================================" +echo "" +echo "📋 剩余进程检查:" +ps aux | grep -E "(uvicorn|celery|vite)" | grep -v grep | awk '{print $2, $11}' +echo ""