From 1e1e5957e8bd13f8a5571a675e92293108ae94e5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 18 Jul 2026 09:45:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(ipam):=20=E4=BF=AE=E5=A4=8DIP=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E9=9D=A2=E7=BD=91=E6=AE=B5=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E4=B8=8D=E7=94=9F=E6=95=88=E5=8F=8A=E4=B8=8B=E6=8B=89=E6=A1=86?= =?UTF-8?q?=E8=BF=87=E7=AA=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 记录 --- .gitignore | 27 + README.md | 277 +++ backend/.env.example | 19 + backend/Dockerfile | 28 + backend/app/__init__.py | 1 + .../app/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 143 bytes backend/app/__pycache__/main.cpython-311.pyc | Bin 0 -> 3236 bytes backend/app/api/v1/__init__.py | 14 + .../v1/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 1118 bytes .../api/v1/__pycache__/alerts.cpython-311.pyc | Bin 0 -> 9917 bytes .../v1/__pycache__/async_scan.cpython-311.pyc | Bin 0 -> 8808 bytes .../api/v1/__pycache__/auth.cpython-311.pyc | Bin 0 -> 13320 bytes .../__pycache__/enhanced_scan.cpython-311.pyc | Bin 0 -> 9795 bytes .../api/v1/__pycache__/ips.cpython-311.pyc | Bin 0 -> 4457 bytes .../v1/__pycache__/networks.cpython-311.pyc | Bin 0 -> 6014 bytes .../api/v1/__pycache__/scan.cpython-311.pyc | Bin 0 -> 5459 bytes .../api/v1/__pycache__/snmp.cpython-311.pyc | Bin 0 -> 18456 bytes backend/app/api/v1/alerts.py | 182 ++ backend/app/api/v1/async_scan.py | 211 ++ backend/app/api/v1/audit.py | 164 ++ backend/app/api/v1/auth.py | 359 ++++ backend/app/api/v1/enhanced_scan.py | 209 ++ backend/app/api/v1/ips.py | 162 ++ backend/app/api/v1/networks.py | 180 ++ backend/app/api/v1/scan.py | 108 + backend/app/api/v1/snmp.py | 555 +++++ backend/app/core/__init__.py | 5 + .../core/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 400 bytes .../core/__pycache__/config.cpython-311.pyc | Bin 0 -> 1704 bytes .../core/__pycache__/database.cpython-311.pyc | Bin 0 -> 1150 bytes .../core/__pycache__/security.cpython-311.pyc | Bin 0 -> 8001 bytes backend/app/core/config.py | 35 + backend/app/core/database.py | 31 + backend/app/core/security.py | 158 ++ backend/app/main.py | 71 + .../models/__pycache__/alert.cpython-311.pyc | Bin 0 -> 6945 bytes .../models/__pycache__/auth.cpython-311.pyc | Bin 0 -> 6171 bytes .../__pycache__/network.cpython-311.pyc | Bin 0 -> 7071 bytes .../models/__pycache__/snmp.cpython-311.pyc | Bin 0 -> 10465 bytes backend/app/models/alert.py | 120 ++ backend/app/models/audit.py | 37 + backend/app/models/auth.py | 97 + backend/app/models/network.py | 119 ++ backend/app/models/snmp.py | 190 ++ .../__pycache__/network.cpython-311.pyc | Bin 0 -> 10216 bytes backend/app/schemas/network.py | 173 ++ .../__pycache__/alert_service.cpython-311.pyc | Bin 0 -> 24225 bytes .../enhanced_scan_service.cpython-311.pyc | Bin 0 -> 16232 bytes .../__pycache__/ip_service.cpython-311.pyc | Bin 0 -> 8175 bytes .../network_service.cpython-311.pyc | Bin 0 -> 10427 bytes .../__pycache__/scan_service.cpython-311.pyc | Bin 0 -> 7677 bytes .../__pycache__/snmp_service.cpython-311.pyc | Bin 0 -> 24216 bytes .../__pycache__/user_service.cpython-311.pyc | Bin 0 -> 20779 bytes backend/app/services/alert_service.py | 496 +++++ backend/app/services/audit_service.py | 155 ++ backend/app/services/enhanced_scan_service.py | 381 ++++ backend/app/services/ip_service.py | 143 ++ backend/app/services/network_service.py | 177 ++ backend/app/services/scan_service.py | 148 ++ backend/app/services/snmp_service.py | 520 +++++ backend/app/services/user_service.py | 420 ++++ backend/app/tasks/__init__.py | 4 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 243 bytes .../__pycache__/celery_app.cpython-311.pyc | Bin 0 -> 1662 bytes .../__pycache__/scan_tasks.cpython-311.pyc | Bin 0 -> 9945 bytes backend/app/tasks/celery_app.py | 55 + backend/app/tasks/scan_tasks.py | 215 ++ backend/requirements.txt | 16 + docker-compose.yml | 103 + docs/DEVELOPMENT.md | 260 +++ frontend/.gitignore | 24 + frontend/.vscode/extensions.json | 3 + frontend/README.md | 5 + frontend/index.html | 13 + frontend/package-lock.json | 1839 +++++++++++++++++ frontend/package.json | 23 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.vue | 23 + frontend/src/api/auth.js | 82 + frontend/src/api/index.js | 176 ++ frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/vite.svg | 1 + frontend/src/assets/vue.svg | 1 + frontend/src/components/HelloWorld.vue | 95 + frontend/src/layout/MainLayout.vue | 301 +++ frontend/src/main.js | 22 + frontend/src/router/index.js | 110 + frontend/src/style.css | 296 +++ frontend/src/utils/api.js | 118 ++ frontend/src/utils/auth.js | 56 + frontend/src/views/Alerts.vue | 575 ++++++ frontend/src/views/Dashboard.vue | 405 ++++ frontend/src/views/IPs.vue | 372 ++++ frontend/src/views/Login.vue | 141 ++ frontend/src/views/Networks.vue | 333 +++ frontend/src/views/Profile.vue | 6 + frontend/src/views/SNMP.vue | 563 +++++ frontend/src/views/Scan.vue | 293 +++ frontend/src/views/Users.vue | 363 ++++ frontend/vite.config.js | 26 + start.sh | 227 ++ stop.sh | 54 + 103 files changed, 13196 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/__pycache__/__init__.cpython-311.pyc create mode 100644 backend/app/__pycache__/main.cpython-311.pyc create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/__pycache__/__init__.cpython-311.pyc create mode 100644 backend/app/api/v1/__pycache__/alerts.cpython-311.pyc create mode 100644 backend/app/api/v1/__pycache__/async_scan.cpython-311.pyc create mode 100644 backend/app/api/v1/__pycache__/auth.cpython-311.pyc create mode 100644 backend/app/api/v1/__pycache__/enhanced_scan.cpython-311.pyc create mode 100644 backend/app/api/v1/__pycache__/ips.cpython-311.pyc create mode 100644 backend/app/api/v1/__pycache__/networks.cpython-311.pyc create mode 100644 backend/app/api/v1/__pycache__/scan.cpython-311.pyc create mode 100644 backend/app/api/v1/__pycache__/snmp.cpython-311.pyc create mode 100644 backend/app/api/v1/alerts.py create mode 100644 backend/app/api/v1/async_scan.py create mode 100644 backend/app/api/v1/audit.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/api/v1/enhanced_scan.py create mode 100644 backend/app/api/v1/ips.py create mode 100644 backend/app/api/v1/networks.py create mode 100644 backend/app/api/v1/scan.py create mode 100644 backend/app/api/v1/snmp.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/__pycache__/__init__.cpython-311.pyc create mode 100644 backend/app/core/__pycache__/config.cpython-311.pyc create mode 100644 backend/app/core/__pycache__/database.cpython-311.pyc create mode 100644 backend/app/core/__pycache__/security.cpython-311.pyc create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__pycache__/alert.cpython-311.pyc create mode 100644 backend/app/models/__pycache__/auth.cpython-311.pyc create mode 100644 backend/app/models/__pycache__/network.cpython-311.pyc create mode 100644 backend/app/models/__pycache__/snmp.cpython-311.pyc create mode 100644 backend/app/models/alert.py create mode 100644 backend/app/models/audit.py create mode 100644 backend/app/models/auth.py create mode 100644 backend/app/models/network.py create mode 100644 backend/app/models/snmp.py create mode 100644 backend/app/schemas/__pycache__/network.cpython-311.pyc create mode 100644 backend/app/schemas/network.py create mode 100644 backend/app/services/__pycache__/alert_service.cpython-311.pyc create mode 100644 backend/app/services/__pycache__/enhanced_scan_service.cpython-311.pyc create mode 100644 backend/app/services/__pycache__/ip_service.cpython-311.pyc create mode 100644 backend/app/services/__pycache__/network_service.cpython-311.pyc create mode 100644 backend/app/services/__pycache__/scan_service.cpython-311.pyc create mode 100644 backend/app/services/__pycache__/snmp_service.cpython-311.pyc create mode 100644 backend/app/services/__pycache__/user_service.cpython-311.pyc create mode 100644 backend/app/services/alert_service.py create mode 100644 backend/app/services/audit_service.py create mode 100644 backend/app/services/enhanced_scan_service.py create mode 100644 backend/app/services/ip_service.py create mode 100644 backend/app/services/network_service.py create mode 100644 backend/app/services/scan_service.py create mode 100644 backend/app/services/snmp_service.py create mode 100644 backend/app/services/user_service.py create mode 100644 backend/app/tasks/__init__.py create mode 100644 backend/app/tasks/__pycache__/__init__.cpython-311.pyc create mode 100644 backend/app/tasks/__pycache__/celery_app.cpython-311.pyc create mode 100644 backend/app/tasks/__pycache__/scan_tasks.cpython-311.pyc create mode 100644 backend/app/tasks/celery_app.py create mode 100644 backend/app/tasks/scan_tasks.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 docs/DEVELOPMENT.md create mode 100644 frontend/.gitignore create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/auth.js create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/assets/vue.svg create mode 100644 frontend/src/components/HelloWorld.vue create mode 100644 frontend/src/layout/MainLayout.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/style.css create mode 100644 frontend/src/utils/api.js create mode 100644 frontend/src/utils/auth.js create mode 100644 frontend/src/views/Alerts.vue create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/IPs.vue create mode 100644 frontend/src/views/Login.vue create mode 100644 frontend/src/views/Networks.vue create mode 100644 frontend/src/views/Profile.vue create mode 100644 frontend/src/views/SNMP.vue create mode 100644 frontend/src/views/Scan.vue create mode 100644 frontend/src/views/Users.vue create mode 100644 frontend/vite.config.js create mode 100755 start.sh create mode 100755 stop.sh 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 0000000000000000000000000000000000000000..b38c3ab9217c2a3ba9c21abb1ce50c14d9c83e30 GIT binary patch literal 143 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5ddfd0daf9181549654a4459a76f4a47ec281e85 GIT binary patch literal 9917 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

Jcgd^)l6xV*trj< zL>#cCGdvxcTJqh3&@j=kBL=bvF_Gp8hoVvrz~`B@0-{Dx)Tl&{#yfi>FGpXxGkJG1 zGd#R2!>gjkz^YMPc3M$e#hqg)>X1Yq%2~&XRmuWZcvaM45OpY5RI9jcFN(sjh;QB& zemU~e(&R0TRf~nJQoNsS)E04jW`6D4Bel_*JI!|i{4_j^fO?u&jnd3dZ6rnEe*wwg Bd9eTh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3f395f2db06cc75b48774cf048a79fe092f44323 GIT binary patch literal 8808 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..15c4386aa5c85a07020d4f5b45a23a9f636cf266 GIT binary patch literal 13320 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 JQ%&5}{{xGu%~1dV literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3a08ddf5009f86db9d473c50bfabfbc7e741f711 GIT binary patch literal 9795 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5e966672dfdd9781797bb0db6330c3c7a3f00bcf GIT binary patch literal 4457 zcmc&%>u(g-6~8mHui2Nqe&RJ?^K`a$>=2b8C`|-`v>PC}7;Lm^v6h{IY}oZ~@60AK zT}y@%uqtTXiUv0&?SfLr77lDAl#-?r|H8Q152KMPSt?$ePhCXvC7*iEo%MJZ+ZCe&^hCuRrzq+ytIG|BAfxB~bst&eG#A6Hi|e2)RKdB5?_lkMKz$ zB5*jyCu~W3#Lj3T;Yd0oPDa}juA~?d8EsFvlb(o&(T;>S>5KT1{)j(W6RBZi&O~i8 z5D75am8eVBN9sAk6Pc4l$$gdsikCf;9M}n;`JCluJd zNxm6>HNRBD{)kk|#c``p~{c4d}xr+0V2%q|Ga|tOqR{Dui34v<0+m{SI0%7B*?y3Jn`q)vz5jbbMzG zFRakeCe`$jQ0Ot*9uf?3UvKzWDx=EOa2}A;@{pt$zMmdH-h1%OIQwKp zF-g}^HW*F7u;Z+(4oatiszoZ6p;R7H2Q!LHL2_fbcb_CtSa~?EsK;a_of=YPqsAIO zk(Q#WY>3tX2y%sc`_!nKQGkYtKAFB7kICn4J@K@X!Hpm8oxho%|9Eua?yZIK%b>=Q zrt+Ei`-V-8o>dI5#C(v4VsTk92wSrF+rQ8M?n78Oc4P6@RERhHU=%ZEFqx9%gyB>& z$z+rczx7xG4TgJ2R^Lm}bAxfoa45_g_?4qH_O@YD&c)M)JrPgFRfCsK#~g@b`EkK7 zZu_TiKy!nrR#7129gEJ|vn0!9g{&>h&v28l{*EQ%=R6@JjuB_pk#$P^O&$UzWV%3@ zgG? zI|pgh6HTY#i}$>{1B2I{9yXk)257no*4Ckreb9`OZyHF$hC=-|-R0KnH|RBux-X#n z>wzfwoDGgA1e&k0xo~*&;MmSmEoo}aozOg;_}V(3f)H}v)$^-(8bUTw74yY=lf_?u z0Pf;c4B)jM8&E7*{Xnc5EG|nXVbvXL3Y^mnXBh5A7PbR$ zJ5~c7Fw+_XQ}&kb0H&9aaRuhVS+T1icC8h2mdwRJ-!Fcchcsa}o5?&B!(cBxm<=qM zPXe)OGV@q@sxKQOLRMH)ItwhFar-Y>Q?A1wU7G*=FU8SM|1Y4946F4fnl>|QUj|tf zkm7#Ca$+X0XfA+iL3hH$Zuk^zAb|86F=WCaWL=PMRaJI;q z00=GSt}NcoKYH+wM-M+<_~W0#y;a$2VH*L~Rk4LaA#pSOGw7}?7J_7(u|Kz1I5Htn zFrU91?tR9fh2MNp9K8Vbeqr)L@w40WW0xO)aP9asoR(!Tq-XLN3InU_UIprf#ech3|O?9@NA=v*MnDxM!^h zhT0A1nJAnH={UvxO>vj7U1a!`UnZi7*xPb)xI0CY6fY5mLmf`Xht5(bb^>q$cgIpx z?uL6-^mG&w9uMt`aYcqZTU;HcB97N1zk(8@N_Y7VMDgD+il-FyB2OLiuv^nIiw`H7 z*<$m$rgTH$$L$4IH!~3()VyK}P$~@}J_L=TQg#nu=MyWKhLFyvY`Yt_bPDD%8yTR} zgdISvG<@sY3;Ht&`Bi!Z=J8&o>;P;^0>^PW*{QAm>SVK4{pzGutA6LmR;~KgNw-%0 z>f~qI>aR{ZwCYzUFKX5AoLD#3qI&|mJE)1RdR@zShZg+7Y+Yxeu2Tzim7F#=|AavE zH8!J;lArLdE5VDwvHe=x54Bg{8V$~JM+@B1x%RF4rfvHAcD**B2O5~9iS57dx!zOq z1-Pyffkv-gH&K7xR}z4p^EQm1EO^SQnzQ*yst4B&s?74`?UlNgW6K_q)gOrzu zwab#2xF-VPdpXcu;*sNpCClE-4QqSeM8gghxI_B3uH3*>|E&?#zzyi3m!_KYP19{NZOdMd$4nsJTOP82I0;4%R2a=ln%w3Q~!h#?{Vn0C&0ZO1nc z*8Q=bf5RW;i}t{eul~g|%v+4aNNki* z*dVKLK~CX=Jd15yR8TBI3+4H!Rj~zaloz6Q#SwH+-V&`*oIxk$tr z9z4c1#?Ck|fUWPrV_d;TxiQ^T{#E^!YGtofHzS(I!+Knjo62^WWgitJkK8PI;ql2f zd2M4wn6yf^9JNdJFIX->+k2JvV9ScS8{~DeuS&w|#da)f0ZTP;(wZfj)~~3kQT9kp zw<*0D)`n{lq_s;lw63V3MfOSS{%Z~EmuT3qqJ~y^m9$~82JlqdvMBKquA}TL!#5k3 zXlny)8!N7=VjwO&QoGbK(|JJvZ^C0-%j#mKF41AGOls3<7xxm3H;4!Z6&|!G~&L#5iklaIzzxx&n@bkK3 z=b?eYSTZ3K-L_kf%j1%&*X=!g_)y=;upCcBV&i^Rw++dvYOwZ$v@sNgX6u-o7?DPS zb0Zf{5+aW$Mv|&bpiOuT-*&z3fSfoHBgc0UIh2qqKOc)r_|sPE)qN#qe?(0T%4$3| zuF86Csc|R~N~n7Mz@eRzM8Fo)AXlZiB-2r805in02$?(+3Cqtq0^^1~YO)p}xbevw z^Orujaq0YxGd~45tZ^bAjhxhlL}*OaYbA<^JRXk7s?H?6FyZFUKPciHHY*O2#ZN!(lQ}TSS0Qo2X5w*Ye0}; z^8CxG`ExIu4ChHRbd$A6T7X>SbW0+Z2t{>EBq1xRpCdkK*6rxtC^*J%T{MHN!?_!f zv;k3Z0j>1<)*m3TSRxRKhm^o*D1013D-epu;fVyExx34uI%NKt-KmYvYGLUt+ z&`>lwLe2BD6h0LVX(jDI_4@LF5*o%p;)gy|q#`Z=iIu5s$~n6-T-LccN7qd{q37AU zz>mrkBhuuOc)fY~!u(HO)N2;Tge$28reh>h5FQ6+MG)8t#+-yExd~47BrGtgbV;nl z&G6QMdp`Zq{A-s6c2{pE!?;^#0gbO+Fvf_~FXmn={BF8%E(5XYMAVY% zNEr5*Zk6OjC=&JaMu<8PxY1K15s9E7(2Sm>`0BQDXyM`@I1Q+sRY6C#K@VnK^?o1} zU{^~{>`ar)gIV#uoOoYqPu}S*GOT3}`?=Vh;kA4EG_gM`_UFWY&E5~4sRM=&d?p{1 z0&#KXD23qvAN{Iuef6cnrMFA@&m68~3!p&mMA8k!gfWfWr5IaiyJ|w%Q-yFTkb6KJ zZtDe{>zZ@5?dicxQ?~Y=T(8Kg`E+{-df-G1VT&i?Nr7c1gmkrYpx?ME$(LUy`C2Be6P5|9B+%q*N#dR?<%TM- z4XccF47Qz{E&M7)P#IJ!C|yHFp(;o4LWXX|A-ujCl4v)SR;*@fcO*Vi;>m;1Pd4Hp zn-TmAQ)!l_{pf!qRoj4r&48D&Azan&0ys%8jCcSZwGT*&DY_ZUR!z9O`p)~-{?*s< znXe-~IvczuX@gH_zK*Q#$(--W<+3yN+^xTWI9hxQQ|}Z${-Y6suwJZHB!C3Z(p70{ z+?1x!aGIw64$OC@sbdunno|)bEaFL^oYJ*?!g81kFs4U(LGfLj0{IDkh}p;_%HLQp zt{t2)DVVh+>PjNuH6fU*$!3f!h3=vj%Ygxcch+ z*~{~V_uzc`YT@eZku#7)jqHEBP2$2UQH0ch3o(mjJ#@>Pr{iXGCoGOLYvOYDyH75h!PDc<dQcAmUXYox%`>7%(0nR*0m$&+L79ux4Cft?k3WBBg zb8l za20tN3vYEbSaZ2yrc3wgf>o_O_`0FdB-6pO%$V-7%sNJhSW8#Hl#$WK4>> z2TM#iM&xezMhlIGpu*VA?Wzo4dy&K`;=pznj;CKfYIi7^c$#1u)P2~hqMnf2T{?mh zC-e~f@FJKB3H(RJ5IpS>HxdsLjCOr3om{SR3n27L3ZQx z4xBFF*9tv0)1w{1a|f+wx)6^+4`w2rk0%oZqn$j01YK~Eq3F?#@vXfV)|8COJIDxh zK}u!RRRAO84VKL__i8KOd8SP>-+884GvC*lcFla}nQqN|=b2W`d>1*M<%-Pzme?8g zevRp=zOOsHQ;m6NL%wE>=4i}&8_#rUO`Edb%{lL8&9kLw6Kc3G86f{elCl;>hO@ua zbh>HkVQu|Zt@lu>Da$^ZV;{Zl_T)XQ@~!Pu#&qW!fwO_4!_ICgGC=Y*bwwWD*PUX~ zf{j+j(>U!rTUWH|j1{U*^Q@GZ&wm zd9KJqyx>L=Dhx5I==Js6c z|LEY=gGHu>g>npJdXm0oyB3-#hcZj5KxWWDdY zy9KTHf%Om`m2Nr)?I6?V9U+KX4_0$byAH Zrd9gN*cO^7hw|=+Nr<{Jk~Y(<@o((RBP##^ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..175c4a1f5f300ca4a706194a3041a8d848fcf098 GIT binary patch literal 5459 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ca01d88d6c02eda1047e09c330ae3bcda9a6e277 GIT binary patch literal 400 zcmaity-EZz6or$W%n!@1;se+TGR+38MPy46o2{HCWF~QlPLg3V7Ir>^jlGTd7{0-j zl4@ZqJ7K%6yjfHb1aD5hdlPa_o~P3>g4%z$yncng3Wx6+eGTg)7#@*80yjwG0^=}` znvsr*NRxu-xQKO9BpB65L{GR#1zDgxeh&dT_L-A?uT1T7;>nyl=@V&cWuVuxXQK3z zg>+6?b8ai%P8wtA&G{3%9t{$XKdEqZL(u+#cd-`x*hOg*zOGQ5n+s% Y=wSG*H*`Aur{#D)Y;~W8)lDGo6JJ$ndjJ3c literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..87ed076852e4906ae2111daf6c2665ed952de8ec GIT binary patch literal 1704 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 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 0000000000000000000000000000000000000000..bfa4abf75d08c2bba16b691304a88b5e4d7bcd75 GIT binary patch literal 6945 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 b&o}djb#~IFIV~rjQke6c&?j3c@&Eq@+c@5k literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3d6e1672fa4af4bbf99ca6f49428f6ade6bc33d0 GIT binary patch literal 6171 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 Ii)^_60!lvoEdT%j literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1f73d8154a96c5941d253f2aaf1e0885d5bc092 GIT binary patch literal 7071 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 Oh);>;`5t<*2>36|ht#eB literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e067f2b1077b33c0a99688dab52857e55b8bb761 GIT binary patch literal 10465 zcmd5?Yit|YbtXlMq(}|lq@I?weoD4Q*^*Z4M;lx3hi%$xY2_|cfE{y27Hz)v4k=qs ze{kD$S4p!84HyWGD1{bD9T|4KDi9P3vP~VpDE8+JFh7X-<)W1z|7_Smf&J5-bBCh` zNy}bu5_BjY&CEIX%)NK+obNkl_$!97>R>yv@ZqIjH0pH!g$LSZD(<}aM6c8RRLAK! zeM}eEhxGBPP*vOzGQ^D`W4t<49XEwcadXHVw}dQuw5y8M#H}GK-8aN+@!C)=-8aUV zxIJX2`_(Z=$Wf&m(Q&5l>o_y-HJjZ2A!u&S4VYT(D}-A^;G8vU0s>3 zfvcX-wbwtz%ePw;(?Bwoi6<2ESR&0|CL?*77`;*BS&nDXSN-g1IY&x1u2s6#RfUY4Aymy7p@-FqZ6bL3>=aJ~@SCxLH6FJ<{=xjCg}aJj zwkx7k&2|UdO^PKPPOx!499FF1a6HLnVz^%$4u6zkV?~WQ9OjY{IBl5f8c?dHx^L@V zU_ZXz-AR(kbZ0ch#yc;tk;{C7>ts`@PVmFWgiaw5PX$tQ!~wQcSYStJ0g+w%6pTL6 z=qp$q*?~e0lkLY3Y0w%}9zA+^)#yVedXG$s6Gz;aNza@m$#gOTcghRR=rl}qw?4V| z%O_uay!zQsp4_{q7~Crwg zB=xAH-Njo%nl)jEofkVnY#eEeXlzkON{7q%vcvdM+hfyjJKVD*I$LqLt3O;^{o)gC zxEwDi1{Q<~#8$%{9GXxJeIQIADhziw4i^Q^4#DB7W5H9E#}WX|R<|x>=1d_AXAaeH zmXMXJfyHc9tb_b)G{R5LrT9!OICygJ_p87B^zpy^ZuOu3Q8AHZCJoP?EdtM2Shx~R zM`jcc7Zu=9ypX}?N;}E0kq9pc+M#7SO86^mET%BAB+G>_urW3f0Z-M*^bAje?Y4?* zz;y1+2a`kR6x+a=b3@^Y^J9|(ql#y6Y+~}v-I+aIa5Q8`)xBCzb_hS}JwaES?#UXo{HChXJ*m?1)x3!_aE80alCWz=9egcZ z^(_cVceJpiLu<}-*IWV}HPn|CJXvnRweG-I-9omar&^xb?1^VC;Uh#h@K{sQ0|2dJ z53HNpRk((8&9#7`^ud53dx z?z^56%?{2Bk-?|fF`f?)p5qhgC>vWxy46pA{phoMiW4Cy99c6ECT~|MwiJoRSuz*C z3~*c>O>q3xryPuC__jeYWfIYkGCb*nW5trl#A57)7=K$&hTuedm15@jX*Lr})8nWA z3n!l%K!Cvr?&He7tye5*K>cgU1T<9a0#9an5-v7GEkJUL18$%&K|XyYNiM7LO>va; z7F#2%hEvH@1~>vQ1lxC33`$HmoW_o-fK9MV#0WyEigKhHcZ{ea_{=Ldi2o51O<`;# zHE2o!+K)OjbQL*+;*2EYahQzsTo@xMkQz${MuJ`xh6Pv+r&vLN&T|y>v~xwQRT!A# z*>w{H1nrz=V#Y~=*sYie4_Dv?f;3pEi3(vhl8!=0kctuv_=+KsyrNj)KEfn$VK%K; z)fvQng7gM&3}S>(8b<#TeW2J2U*SI?RBa5SlzVvhWKTb=am@R3=EccnuUOM7)%40W zz1jYP%{AYf+mqXq_gvq9WB($zyz9PCw2er%5!p799W1!(LFZjEitY}{-66X>vL~OJ zbPnJAbZ#;?ncpw@_Q<|H%eLP*zS=F?&r0^Qvi)p!1Z?VCa6WG5m{J;p2FF8F743P zK?z#BO0ab2Tl#UA_}K-1c5@!id)HT@*+Vscu8ym}+fcH`_flPBMO_=!HC5ELbIrg2 zwvlr%N}3cr{ zj;gOfT=SFwvJe0`Jq4QR0RR#bMC-FCD9T*Ky-5&?rSII?p+uU@DJHc8*W8e29B75C zm{0Q^Ns^j@)W3>+fX3zsD@0h1R~)DN2KqRTK#nqrV6He+UVzmFGR;Q#VEevG=uf8b z!mz+q%qQn@4?&;&6$-?B@*xPt8cl^+^#Y19l_Y5u{b{Cg3=M!8;BRx&*WN4Z;orzJE;$lgP?uDZ`5wmPeamCq4$*DT3#97*`p!|U#y(2t| zX%xs9$OH(*hR%UJBL7fq=vF(ht~o5@6u9Q-!W)Gh;a`FDZR8X*HS_NNBXbI18veWq zKIEo#!Me0-#V5LsORnRx>o}k$9@hZ9uLKWQPiM+g?pxwxlP(j9x`+pudf+dH=~2V$L02M@q-V=hLF?{ zk{d#h@`4dCi86NKYsyXKNAsggoY>qgHFwL+-7B2v8<2bhvTq<8EHu3Xbg6Bz4S3d$ zBhro|@{S|-2gRl_scB4Z8WWB6;J9Hs%eyyWf$Q1%D2XA16?+=2YHl{WFz--vEja`~3>!R$sm>tKDN|bOl<1@Lo zVZgxc#X|^k-<&8>5H6<;SQda7wE=`_SpY)3m9uOnF_a9Iru-=y`FOdgt|xqI+AGx0jSmb{LpeL75TtRwhKGC?Z;C<(n5TIsAi`GS}xc|7+dO~hJ@zuMc z^L@$rzU+KIJEjJcS4Ml9b^0MzR^4n|Xk6-9*)6(HNbVD|`vg4j)cGs~=*ac&-T2

02vVtE@qt%sUPW&I58$D41VX~T zA^7|q9;y+j?i)oQ@(H%2#QIa*`vHo15KvJBGOh7oD>BGA%ArdF4fzqa_zVSQpp^m( z&IUp8L--1ZO9D$kGzJRZy>A~_whkQ?!o!x~gnFx6>Ji-sCHFzueUO&FN~r3j=<1SO zU9zhyJO1YnAvl$lL&)y|>;Kg327&_ep3SVn3XcRP?zVE?Q)a6L5Lz9)7T;U4%LZ+Fk%pAN2C>AU51k1&v@G2JwTt-mra3Yz4w<_wf z$_25!0jxC63YV2yFaYypB+aJ)qQ;_8jT>tKk??S}2AC&9|W&1&>!EcdN zuea;J9=Z{N4By@<**j%>C$yv4|HvXIGW#WFzs&51tiO0|qv+_6938Tw1Fr3FhW8Lt z`KiVJo1ul!@-ET;p5%W|_P+y>@I*&qH(1%k(d#g8QI8EaXGX%Dpuj67#oYs@bS3~#W+eAOt@gMP1>J|@+x``Fd&SLv1>JUW z^It)?L)`pV(Al!|^UP$>+n?#mMQ&#kl zH|nSKUi}y}sVwKy&#C6+Mt#6TTU3@g?zs*%FR6T{$^$L>Q96=J5?i1iM^Z^oD@^|f D3@IQC literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6f4185d1993d41ceecdfb4ce2ecd3a3d61f1954a GIT binary patch literal 10216 zcmb_CTWlNGl{4h!`>FTCqD9$KT)G>%b_1_piD21|BiW{-D7Is6vjoi<*>w0&W=Okk zU6c;Ig%;~>*w$^-Hf@#ta2h!13Rvu-K(c5*_j3jy7Q_Gs0_<8&_NNl04&pHXGd# z#f<%ShKo_$IN&C1xa|U&=Mv9-fh|!}rDrWK;Ih~Ws8abZJmNn-)yoxr9k(@un@J;fd)L4u1j!%tn_Jtq*%UQlHNjDnXJTNnn(ZnqqX1t`>~nh zul?!MI}h)@b11&|;J(Sd`wvX+o&3h0eJ_Aq1O!peN;xVy8p-AaDfxu}q?KTc{$cHh zk3L*YI5dAD^X+tAEM6{Ok-(&J8YyN9V#N*BwRhkB{12zNzh@ti+>G#Q^cu6eHIGsSdH$hPDP2vHW% zj&1%kfcH2bS8nYu$6amJZ}I~FvBNBt-*c3kk_@yr9p+u0=XP>sT!(&*b5ak_l|9X8 zjuV_W`CoFsve3Q5U+2EECZ)NSLOOJkW8yT>+^6A4* z-#ruuJ@w@lGlC$AGN@k;wuNy`a{{U6KcC50#1oPP8tv4a*}Sar*EEmbN5;j+$xP}J^&_3nd8@Zh==dOd#pSoXp2?~L!_K8zhD&d#j823_9cZt~LpC$rbg zJf17YfqeuB^-E`88^ZFm47g9? z>d{>aEsr1c#9+@uM-1?wKdy`&T74S;6vizjdKfl7bObj3vq*HwD|=yr+r2~F&tv@V zR{Q3i=M^#sMJ02MvFBF$R?5F0RK}*&vFZBQboEqiaCzt5m_nwZsARekpIC9N2&?jc zg_ZaPHGZKUzfhg6d6#?ch7@uEib^gtVmp@4t-P>0{l}ydn^$A=_1Jv%oQzo@!K>#kAS-|gyJ=K!?zj{|O!j50juC??Zml7 z45E@u=+OaM4=4e{QtknJkj z-WZ!OO2!+pF+&u`6f&lgu}*D(fx_5^FJfXFfd;%bB>?Sg z6M<<0kK0FUlwq_+8ANN8ArK`JfiggJo0v=ly7@FZ_CR#(HGA@uNALk#{F+A>P|cAO zG+!C4Qa+tqlr?Wf76n=hf|C`cYxcLu@XU3NSK+0+%XfQMPbab2C!uC79z7+?(ii4CVrBmm>Jb zcK#?JlL@b75+*WKpwM128;o5olS%ur4Z%LR<#zxWGl3X+1J#q&lebPbBHh(wHF+!9 z2zOP_RL|Tx)9CI~2o{y}HKKzG!J?8us4oxJqdOFWMI}4d2@e*Bvx5z6+v2VmHrl* znO~(Q;s8D#M1b<3g)-MNxqRjlIAj#)P({vM7SoHOloi3DQYVZ4)wFqEExA4IFV+%Y zX$q&c*d8iKuK?l{BBv2hL(gQ7=bwqGXfSKpGE|NvvAAEIn(9`!qwU8?5){GAX4+( zd9kJbtir?rsXMQA1jMoPS_MZ6Rx3J9>t(*%0bbpTVZPgu^4c)WcRRqVTWOf@cBK3^ z47C7|W(Pz(E`WD567*C^3n)y;n+d83S?6Rk~gjB`tKRBIk;bIHt>ROryCV5*KC2 zIwOL(kg4R$>4i)d&VnH6%U0wv_#k>nBx?crdaj(kLc{&m!(vG)Ywl~1Izq15V-+AH zUYUazWA5cOtj!~M9l<#S1b{{a$a$p;_>4J*glV4i27-$SFsZa!m{+k4!Fjml&j6UD zFWOT*TRnU0Y{!sh`;O`>OIK?0okGh8HMWgbPb?j%9YaYSg`$$t#?D>UGqnTD$L>Pg zE+{J5)v@1rV&%l|$CdFZb$qHmJ_STCEIXIym%Vpif;gRp>K$LCQ@k}2ufDwWN^Snm z8HL25s1!nlsSGZ;v&x`nd7OdVz{y+{D6QW*{OhL1Ll0<}b1%1voyz0|`9n zdujj?fRQ!d(ep=VP9L25Zuz$W zOf2mr+?_NJ`k}zTQ4c~I(#$&k8g-o9N7eTDO3zAmW$^uEz31x+*#||X5GqWV@YPMK z)Dos8z_bffo2SL(ghi34Ml6bSi=H;t5j~28=ueL#vBsiEYfs&uKtxDCr&uCFuiys{ z9?*O(1!ZyH-^1%sCDVLxDpF3%qFB`Y3pq)zONX%ox(d_sz+KU6vm1Vl<+bG|(bhzN zL3oE;|1R9N@XluyJR_>3LWZ_TqDe=mm`*?}1yGTH2EZg$t$d?p;e)*j!J?AhMr2^w zTaRGYsb{6;yrUI_{J-TO_@bswc0vPQ&l;`uwOG&|uSsWMNGS>6t7A2~cNSH>isT5s z5BJk!HQycZSb7Z0Dok6Dy7NjgwKv4+982420**J)4 z*)1&!1IAdke&`i$E=WK1lJ-I`t1m2J8-jnr3ceki3!6-H5oo|`^K$^kTHIV(|Pz6vU!S^EP*}&E6GY3z*UKE=r}#^z%nJE9OP%Mw-eE1S50=v&tj|5sKy1iiNoZGH5G?g}DSWXpiV`Gbk=*M(DpF zcr`z67aTl5eo0TW9{%vBYaf5G_K)}BoA0AfezNxK_i64*bvr4|!K+HI0nkFA;`Fd0 zjsJF#uqnh0_N2nU8*7x1ss>U^RRpsLEE|m;P(tt_-0}hdlR~gFBO|zoR8Lh;-8$7V zed`p>P9$jXdEzd3(GCd=PMjFkyo)!4OtGBHN+^u%D;fRH<%YyOSG>&fWqS1D#SSzf zraF#a`m}ENRyzq_+{C;*spo1E4Xvm_pr#>$WO!mkKLM}e^d4a!{9cl70|nYd`FU6hd@tnr2De{%a%pgXqkL6qa52SP z8eCLqmIn8t^5nAaAL8MgmL1d*8`!#Kpbqf7Z=JJ)+9^1B=6!JT+{Alb{0{i8W&^dE z4Q$;qP*EQbXW4eJ^wtKpZW*Y-F!r>A+QAKM-7-)+96Wp|w}WL^5wva@sIZ?OTj%Tm z-xFH54AiiL-;HBhL9Kve?#3~7l;6XDbDgt;T5bbdw+vLTA6H}twGhq@D{7%&_H8)3 z6)eMhLMzU$qrwh;0()8k?h>?OPaWlNam(Nv)@t9faWjhVX$MO;Hn4TeKt1Q+hjDf* bSf;ZNc3YnPB6-8V3oIAV81=Z-SIa@z7ygmPCovZOS?<*^Ct|ML-fIkdzyMZD}A| zQk?c@wIFV%9mK|nRp0LJo;;P+DxpsGI(b%2^s;jumsx>9~SD>|1Gn4(Z z-}f45bOWHxc$2Mc6a4zU*YADr==Z+seXrj(n~fA)w{JXi>dGKR{ZEYKE_F8W#eaan zHOf!<6+vo3F`}4IjwmNoBdQ7Yh+2W^%AjUKJEA3FRZusf8_`YZNAx7F4jM)bkVhRf zju_#t37RJ85qiQrVxF*!SSG9^)(P8)ZK7sevVPEnpf`~ z4~2R4;P_aWR}D;_<@Ni{gvX~QM}uAkuR9nBg&^Wp@_PShI1nD62=E4k{y;E1I_%Z* znt@<|3G)VWI}iy5c*~KKBw+aZ5qm3UOMJ-xe?*clpb{qqz-rz9-f@Vu99U`)mPgz!oqB%8o1Z zrShTUYJd56)CFbBS6tR!RMx)YvK2*TD^^^#vZ!q3ipy3Nm8}xX7L62)kE1BRBcL`= zxj+AEGR`h|FV*>Rg}Go1Xe4nB#PyPRO~B-L!@n)y44B{@ne)%2Z|buPSO+ zlV1e{ua~E@#D_sT5~+bjwshsvgZFRW``LwiAO7I8`B(0L@Yen5@7I87BqGj9Hyg_!grCe1RKbSuPU5HabN!9>R;W0A`ZBM-qm zWrw!YOl!C*|2!^zvtqJe5mo#I#EPi?j4G-*qloH$Lfufy-=czw(g&%q_@*c-s)#D1 zs;D}uiE5*|s6J|l8l$E=s@o=L1N;luc{9uyoI^!d)Lc{p6}J>+g9Lf6pe0n?S}I>L zMp@r&rONo#*}f}F_Sp=pS`)!hTZ;W;>7&0FR&%5l6Mp%(_hv5M|H=H)^yT|+eCOWX zj~@K;Z-nQMdd{glt36&5gTfPow1#mZK!U)a9Kj$4rVc?p0uO=)1dRY9I1JJXAN6?4 zc+GQ>0CSeX7td>tj|am6hT!M5AtK!{I4Hb!VB4Wxhld2Tq$;e1*Bl>bKnBwWr%s#* zFucYeI2JjL@cO`uAe;f6;WdQ1 zg|JH=+4YAt-OSWfxO@D}=tTFi(XrEkNq_g~nKRv?03n~DZhqV$)2H<2KNG8{_5UKdlSykybA#QKD8uvK9?BrBRu(>kjBzsjt(d2aN1rI z+qYz^OgkFV<&JbkO}erc!>+b;Reid~o33t3yE`G8anNPPhZKM>5yUhZH)Sxrs=cI* zMJ}2zn`7p*-uSBFlHsE1vMFZ5i1CtfdM&GWa(ZV%?_APbUNv1ZB`O|^j{tz*+>mf* z^&2_;#)N)j+FB8#zkpXjjlnArFOicRBl7bJ>bznl+~kzVKjicxE}=zY9h8i7ELT$G zDEV?g&!|(HTs*GLg`{^`4v}w2bI58J`GLIM@|a(7nzTlq8W%Z$6uX=bo>#79^a}8n z?IAOI{MWR6h4c$Rkh5OM}aB7R{+;-ll@Oyd$cPm*g$-HbATIJ`1CJUVB~_ZpyV2 zTB4JWL~cefGvSuybDh@%*VX%#Gs^RZs3LC9=gfzq203O06>iTzjaTNw`B0P!ugb?x zAqyavUlr8?hg1T;gu7qk*TzM|KrRp7qk4Ir$W_C=`5dQ^50Z<1`q+1t2_*@ zreUvsc~&at{@xk}-#LS?lUWad*PmdfB4>QZ&hpxmqt7EhRUeN`j`5nYsR&5ydOzW^ zV__mefVL1hCP)dbSXeba>ErcNj6c922GwXV$g4tuFoUm{*MJ3OBE+kLfk|GCrW9Ta z+!vTIgENEaLx8K2H|=`Tw{73>_Px8d9pcrerp6~3f#8##Jog8X1uoSrw$Z) z-B3=z#htOO)9M9l`E>uqvzO0a@40k7c7CCzKCXWK(V0i5^^1+o>6X*j3T30wE?wNzPc%H~Z%TO7`X`gX3qKke$BJ(h6w!Y}Ri&6X$J zKKOmw{9nBP(VIB*EW7(CxBIA&xKP`{)vif5wWK}mvt>6;3w`V6Ui`)5cOU21Yv>aGEFv;IS%Ox!gDJ~nO@s+gryf&(3 z;=o{(AQsF(X?(Zj3X2SlqE3m*uzUo=sHJ5uzY-K$=xg5UeGYJ)CcQbYi|UvOz%DAS zP@|+Szj{UmY+A95NKwGzfH`||wNg|Zc~H>>%(*$AcuHjYeo(;)bH@6`)&QHj1$!3SM(^D#C=`Q4!82Ff23h zudyjG)d&1Ou+U?#3+e#Qi^7t~X~oA6KKS+0&F>2hc@+8kAGE7TQ{K_7fZckln5}?T zV7E9C7{W0P<00QD*vFtn1DJ@6QfR3r0=$tCIP(!q#g)TrBjK^hsTUZO*M)9v$4~(S zwLdjH@YJE5`wr|H85;BrY}+<;@Swm#bw>sc4DTA=;WY?Ml#Wb}M#3kjnDKJ~uGIVt)W=I3e+xf=MTi<5g{hYf$<$f&ber$nu zadh*nI!UhrPFIVZt`<0*`BKxxmdh>3+FZbcs@hO*UP}OQHM){8-xki*nR4|cT|KO; zk8}00bU#P;C&&*9Ih@x{zIO7)i)>XpSJj@XT9vF?#a4B3Rb4UDVx=cTxeQeV07iQ? zXKx1dW@p+~y;$zx%9~Q<-ekF#E$`&YJ7e3@R^Sgp99nY{kp5XxYJ(F}lGi_M3yE9Z-S=C}|C)c_z-PV!zc5&W~8J)Tb zDhDvV2UvGyL#m=JS(k9`87o!U^^nq6R(**egWz8>6_m}L(AN}m zJrt1%*Lz;%dfz~;B)keR$u2on1&*m4q^1=|zjq!uRNmy9<8+MTvUS;-C0t5QjZ5>D zuq1!pPz<$-)NSS5RfbE%TUtT)7bwTFvLX*!S=n>Y)`Gs4RLBcP8F;63bk9K+D ztMeMa8u%h`3W0kupGe08!Xh_>u&9hnaYfZq1v{$oYve|Ezjg*x+hzG(ftve&#qV^| z_7cvgm#6p*(p~^3G=6p4A%{uYSBTS5HDESEKh3Afyk_Xqe00(zOBc=UfKQxnQOzAo zA%C4#T#gYAYmK1XByhOzqy-laZ}}XTnpaDV$h>-N z+|Tgp$x*UnJ{2Aff>IRX^^p*;QwRyhWE6P>w4Z(aoBLO1@4xc>oS8a;4HTYv{-E@< zlZJRbV!j8z{xvAKg6`Y$yI)n_R%baZ=;eUXmoIRxr4)ZH%Jw#34E^sMca>?a zH+Hc0W?*3{dwbH}&e}UTdk5H$9rYQi#ZdkbL2O6bTE$sC@qwhZC1Gs=Mh8sN<$kmJ zjp|!o*13vvu1Y!klFq&bo0GFO&+6_NleS*q$}S6VWtZi?rQ*`@g)$dc)|x6?l`LB| zdx$OT<;r?VT2rdbn=JFrHnU~jTv_)+m7=WYp-N?ivH<>BOIa%}1umYrd?I6_%#MtO zs%xKZLv6G6vH1||-Wq!XTH~yZ?OL?D((b02y#i=x=Nfv`o{oi< zj@ifO8`zeOT+7CcS=R)-8NjqPW6Nuq^{lOjv-KowJ*BnGt#do>?oGJ1!Y`wwYL6@9 zCvFBa6ov#icjB%r{!fR$m3VeMaXQEzp5P8oNV7utoMJ%vCEoUAi;K^}D@2x*UbpR& zW65foY?vsz+5705I*c@_$99{WYp82h^Zg zRXqjsMY!b5AuLhC{E9mT%nB77qSm4~P;^OISpi#;V9ghk=9jQwrNDd&Foy)^+3TmaKJ)(jswvZ^8lJSBZ zYN--?N>pN{c%=O*p>+PQtkQ|C&MKWho?CkL%Kca7V3$=;`iMfw9D*E#MKXsWB$UKb*o&n%3C)ynIEG7_Dt_4G=Ukgl$D11aA^k5ocLhCV9 zit*TohmQCLhYs)BHUzgrL)#7w4GP+xde`vweOWfin_~*3r&6+TVXd+ma)8d;KtYkwqZdxXSV zXWdELTG*tmMHW*lV=+3)>;kpQRvT9&tqlolLp;E>u7~?#V;k4ln{I07n)=f%9bC%> z(8TJYY5>zFC{pH5*&36!#`t4jWu)Agc5Rsra9x`ct}XCOyGQ2&+`8=v_bB{64RFI? zvcw=UP)MaLsS z@`(7XNO1%4MLhwui~AqV2oF5WB%}~~p4S7~!-K#3?t|Za-_z?^ntS!$wZB8tUTk{l z(wh%H{@}sSe+pU-Qty%;DCpDTttd&gQI`^^_6UY9A|Ryu42EzL3WmBEhDym0g@Y%fQ8cWe{c^(R=cc?!{!fR?{Rv_W7#+iE7*}*wG-hXC(e`57!cJ*cs zpmTG~0=qVExBs;L{m0qHN4UmEQjJ@ZjawFICkMMx;UwL;V0Lll?zyg{c}q6fpER!r zN#Fd)Lyfu#s5^jv)>9@+)_Mn9T^`u#a&%>=a)X6{*!IQRrkQ8b^(`}hm9A^%>bld7 z?OfyfbbA-qzAaW@Ev^RE(B{qRdyPt0oXm=e_!@SA&{Tfb8X0$gymf{v4Rd1kyBGi7dlNa@VL zI{<+CkOSqEtv;czD<;J#Hxg2eQrP16tO&bGhP_~eBdW?`R~KOy35&GXMcCCc>;-;= zQSg)}ZPyfG7g@zuV%Ny97Z7ETtOe}aBJ846|CQLaGVBGibX1$gt|Qn{Jj`Br2m%;~ zC>iI{@qQcr-xCuC3*z#*0-hmaPS^x0J--a-pXoQ=D0pw+7l~dE0;zxSo9PE{UA}km z0=Os&;;-P0{HqH~Z~s7eDA<66hh1G=UZo(`Zi5E!v~+Ksx0f*c^JPW03g!Y7Wqtra zwy%l{s1N?XXa%qbC=@yoAEm+h3yx3ZT{#X@*A&37%zo^I(3atM&{0F#wT4$bA3}k^ z;~@m{4~C5=0+WFk&oG`BVBh_Pplk?MG7q?ce17eN&wqLy zygxgj5Yvr-YzQ5|&>({22%-qSi(nc6IA%m9cLaXvP7W1VFZQ(8oY@`s-o!y_~)` zq3;E%jGGnx$%_8D18l_xu3|&XuvA%n-FfBPSHGREtOHkoCL_24G#O*MX$z~b|2<7r zH>Vt%l8#OD4C~m+Ikv`VEa7G=Te-^CbY%mS>A*4_P$pq(VfC$0sMecu4aSUVPLC&ijreKQnuG^MK6Cac!Y9b>B=;i?{qnXs-J zwz7q*Y)QjrkVe&rg&MJaW>#MZWop2+V`I{_asGhd*AcT~nR>R;%T;>QmCaD570a}e ziW*pbi7MbjP%ilDm+n5Z+Wpc+;|5C!!!-Xcd9Y z00sD?YggbSkM)o%=bv9dwP2%tg_y9_0w$#(@E2iHtq_xF(}`wnDMgsHrQIKHL;ZPr z3>y~OcuCG9^I8c7L4%7uLMyU=bGT1j&>J;6KsJHZxr0Bad)0q`vajdUJLRl`jEvj z;{br{-Zh!85*%;hbCL;-DR8g_2^E9vC4{m~j!X$8oj0BU^SD68&N*Z>J^$b!(j^Yc z0$l>Dul<_-HT{jsE7q&lm}b#bhFistq$`})_P)0FR^?6iTvf7ReU>r}rO~fiVR$_M zyl!qovf(kdVk1|v5w?Q`PwoMQn1O5PnHxzqY-TIAa1~p?NMWnIW_rzZ#eCHq)1)C{ zdChVo!qs=D>id)R{cQa@u6`YBThH0nr)-;&woUV4);7r522-}(N!#vE*Rr<#oNa%~ z_EggL6l*)e*^WT7trgROSTwga3$FZUVJBjO*OQ^Z13gx7j0X<`P`yro_I2#+n79~W zAVeZC0z>ov>TpiM^cI&iv`4%OrX=X+Avh=r2S_9$Qr@H)Rr$mvh=)pi;&Ka1gsi-2 zRE{0;n{pKc85DKw^a$+!PklFr1ITc}Sxg|M zdLR_Sp~iJ1sEK8gnUOsaQ72nDMbF1^0ev5k5XLTDKq*^m($;z_a`Q!46>VS$+sN5A zrfgf2wynY>hAD3)CmE{KcE@!8lB1DxbjLK8>9hxKoW6F!(|*&+dV0X%MdDv`i~uHn zj3yk9H-V5d6?~q|Ze9c1eL!WTlM2Ix)1Cawp5(Cg{*{wlEt}-(KXQ_*C6gRZUHQam z4pYA5R4y^)Mb|JGQSmC|M6fvU@U}4JAH4murSJW_+?2O|&6L+47&^Fb@8R5Z{{ZTc zO!xeI23}$#FFk@Xx{tha$>i5aCO^UPb3{Z|0CIg?0of&B&Q6`|^moaozwFtIIQ3tJ z(tnz%-$|yvyA7whbHUv_vyXMJ=G?1EJl)WW_qr7?fia1YmZ$0NzwGG_{ybkf-IbE* zPOZd>OQLw`3r*>5GDS zDFJ^)BKeArg%wC4(iJ4mADIX*%1yG?igL#{ZtXWYhV@NbE6PRRxV5T3UQzsG6hztB z7R&_M+Yr}zxI%ngRDQ=J+dmkm_r$SU(cD4zfILD;NLu@!Xzu*+{S%q?H}0S2FS>uC zAo7j-r~QlWpC~|m{T#$V9xUc3pe%wHI$`uDpEp<&cL(s6`!Ji|GjiP7oW`Zu3``pqnKh)f%e{15l97?V^ z#5$kioKK~k-%2{a#X662&Z99)-p5f=NOsO2Bf~c)xVru%-4FgGF4)Kf04|Fr+ohI^ zZI|0JI?8~KD%leSktfXeeLPjp1u*d;=AfgRdyyTJ4+*4lcd)GqAEmGRfxzAd$d*6 zG2?=(DMM)tJp{ydr{O^4&ewL{c3ks z>c)&ggWJFeV4I*Z<22jA&@KRjF>5G5`y(?>J{`ture2tY&(@%EAa@}@CNggYOwm8L z%nRFn(z+J}-nxxmP-1M8U7jG%%PGzN%r zA@C7VvdWdiyBs^wv?P|ee9qFw0QqNMR?=^@;(>!Hyp8unxf^_IsFj(ym)8VW+>Zjo zIbhTPmr;>p+xLF?(bA>3mL?Y$pv;`*LQ?0+`3|5$SWF?PS7+wV{I`B{5_ zvj4uirPL8fiTk*3f=Du`W@3bCV^aR78xed%7)B39x zI4S+=u1mWv?zy}twg)4-FYTUwk)=Hx?Mcuc;Y%ehSo2M(YHzaI%T{-C)t&f(l5z)7 zhZ^`g2>@^`sWHZ^;GUQLI?2nhgUIQ@Pouu1S!FY;Z{qY#34PO|wN7;A5a%8Y1+G>? z9q_v_e{SlKJJlrDH1OopN>&f7epP8IA=P5?_VdykSn{m#xYEedBEr`eD8vvG`_C|R6G0ro0D#;glkYh( zxA6Hk07VO{3;BNft=akMU~mZwjwyo{Kg_;B-nK@^5kh zpFm1jbjjxwe6JL(fC5eb?4wu2d368nk3N6>ri8w4KLk$KM8MC}@ceEKnPX@P1j>(c zI`dxBX?A4Q(PX6d6 z*3!yZT2q$QNz3Z@C%LZ8pR7xE?P4vvIm_;p<;kSwN!D_JvmA)2$ca>i!3k$qr`Il4 z*Tz(r3qIFmwa3ClSn_Iyhllgd1YQO6Hq;A_HF#sHgfQN6VKlQaCo#R5*ZX|v{ps`Z zMxSqD$`4;$g)r^&Jr@}bW}oPMKL6AhCKKghY$5>1mi_sIRt#F!gztx_;6sEAzEJ!i zNf=ASVBV+8#OdJz!690X6x-PXpfp1906zZfTDH#gYIfVr$1`S(St$6rg=*$}#)>f;)v|i_ z;LRs8Wf&``8arm2Zq{b(7^|RKyJp939?eu@tO~IV-P|m+sEXP=sK_^tH0_~^(w2Yc z*^Cmi!$Bc!c}BTP!ig$xC^ zx&C?e{J~F}=AZtRh3nfv((*to3pq2eTaER0LA^&Z6x^Uz|9syks`(9fC%K*>l9mTz zS;(3BH$vGKP0e+v7jCotbLzQ+^G$P4-?hL8Sx8zQh-D#X20Xv8-Udy598Q~tdd95=?0SRRP@ z5KA4wXB5^D+-A3BD7ek-oPX->J`&6Wu^?o~RFi^T8sqi#IAx6S@a(oZ`|Qq}FOg^- zh@~J?ZW>j=o23B69pp9#4TspLc5+LM92M>-BIKS2A}TDuhBQC}V@m*lp!3PMX*0g)x-*b;g!I2s%~8JIZRHN{K_BpY=}atMmo;m0D$C#Oz@rY3nC zd>yI_xIrKo>Ka9VLxF^&RY@SY#O_6`VuVPSOY&zO;TaodMp_Hb4|!~%$%!*eIX*&n z58eR6*Ix3SO6I>|Vg~|3spZuB5BiM)bK1l_fGogVsnA-08I?kzNK>m4U-wH>jS2ZL zO|>UV{nC`>0{Kf*rVHdRqgE;4jOU*Y=qW{Sg6b*$rK!F|so$c(6?1R~*Yypop_wx@ YU(jZ>KAi$4LTUJrq<^{0Q!;7(4@0-MFaQ7m literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..80de19125f624e94815a1c650bcf3ee9869703c5 GIT binary patch literal 16232 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d4048a4f80960b22b4e56268235bd00ccd3f8b07 GIT binary patch literal 8175 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..58e7fcd7011423ca19cb04a08ff75e5d0d59ebe0 GIT binary patch literal 7677 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 X`Q^FelnD;y>F`U^{?#*j$pQX13>D=n literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a83527813f9d52f091ba5d6895bed22a6cb9997b GIT binary patch literal 24216 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..be08c9abf5f02213bb0d60202c9acd1653115a2a GIT binary patch literal 20779 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 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 0000000000000000000000000000000000000000..b8fad613d64de8016a093e100c469ebcdb7fe16d GIT binary patch literal 243 zcmZ3^%ge<81a-3_v*LjCV-N=hn4pZ$5;0hb~ zGZX(jcB&$D1|w|EIEf!5Cg@q9WP&FDSi@RN0Xu`^@Jrw%PMxPuVbDVzs9H-+bL0qeMkOD8HWz^YX| zkI{MgGy+v&gl+tN0WadpNdzyQsMG5;3YU4yJE*#HAz@V!+9t6HI|6UUVjY*8d*H@B z!Z~$Yo=DQJ=@{Jf4!vqbXnU-4NSK(}Gp$47Vi7&?I&MF6*P~{Ar)|2ewn=ws9M0fE z?gsrAcE6esIZ0r6gi+I`eIUr1mPNYUu%IGwxyUo(^=v+!66e$*{f=SOa4F|Ys*QQW;^35@S43}keA=7G8 z8^gl0jI^l7ArdTEmY;t0l@Bx%^ zprw{43g7i;sGavbBh2Z)Yj(RELB)-LJ#^pTYz0Sn!VovFzJ z{oGX)o76t)mqXH;T(mdNy!8>tcu>`aW(C)c$yX_6LjY?I^?NKT!#OF{>M#Pv-KQ-U zlhOR<*7jE8k#Vo__5H1e@vyNiisMjYYv@_#cBCqb;-iJZ>)l6h4_@zYMn)RS>QBD$qqBahFigGU zr}bfaeKZrzMqVH=|4DP49A%M`dYt^+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b636b7c178e73a8923dc710020bb7358cc45312f GIT binary patch literal 9945 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 UZ>l|`Pw5Yl1WfnZQ0)8v0jQKqX#fBK literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 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 ""