Master #1
+210
@@ -0,0 +1,210 @@
|
|||||||
|
# IPAM 项目开发进度
|
||||||
|
|
||||||
|
## ✅ P0 - 核心基础
|
||||||
|
|
||||||
|
- ✅ 项目框架搭建 (FastAPI + SQLAlchemy + MySQL)
|
||||||
|
- ✅ 网段管理 (创建、编辑、删除、查看)
|
||||||
|
- ✅ IP地址管理 (分页、过滤、搜索、在线状态)
|
||||||
|
- ✅ ICMP Ping 扫描引擎 (单IP、网段扫描)
|
||||||
|
- ✅ 数据库模型设计
|
||||||
|
|
||||||
|
## ✅ P1 - 增强功能
|
||||||
|
|
||||||
|
- ✅ **ARP 扫描** - 二层MAC地址发现
|
||||||
|
- ✅ **反向 DNS 解析** - 主机名自动获取
|
||||||
|
- ✅ **MAC 厂商识别** - 内置 IEEE OUI 数据库
|
||||||
|
- ✅ **Celery 异步任务** - 后台扫描任务执行
|
||||||
|
- ✅ **SNMP 设备集成** - SNMPv2c/v3 凭证与设备管理,ARP表自动采集
|
||||||
|
- ✅ **告警与异常检测** - IP冲突、未授权接入、网段耗尽、设备离线等
|
||||||
|
|
||||||
|
## ✅ P2 - 前端界面
|
||||||
|
|
||||||
|
- ✅ Vue 3 + Element Plus 基础架构
|
||||||
|
- ✅ 主布局与路由配置 (侧边栏、顶栏、面包屑)
|
||||||
|
- ✅ 仪表盘页面 (统计卡片、网段使用率)
|
||||||
|
- ✅ 网段管理页面 (列表、CRUD、扫描)
|
||||||
|
- ✅ IP地址管理页面 (筛选、搜索、批量扫描、详情编辑)
|
||||||
|
- ✅ 扫描管理页面 (手动扫描、任务状态)
|
||||||
|
- ✅ SNMP管理页面 (设备、凭据、ARP表、接口)
|
||||||
|
- ✅ 告警中心 (告警列表、确认、解决、MAC白名单)
|
||||||
|
|
||||||
|
## ✅ P3 - 系统增强
|
||||||
|
|
||||||
|
- ✅ **JWT 用户认证** - 登录、登出、修改密码
|
||||||
|
- ✅ **权限管理** - 4级角色权限 (super_admin/admin/operator/viewer)
|
||||||
|
- ✅ **操作审计日志** - 记录所有用户操作、资源变更历史
|
||||||
|
- ✅ **报表导出** - CSV导出 (IP、网段、告警、设备、ARP表)、汇总统计报表
|
||||||
|
- ✅ **邮件告警通知** - SMTP邮件发送、告警邮件模板
|
||||||
|
- ✅ **IP历史轨迹记录** - 自动记录IP状态与MAC变化历史
|
||||||
|
|
||||||
|
## 🔧 技术栈
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- FastAPI 0.115
|
||||||
|
- SQLAlchemy 2.0
|
||||||
|
- MySQL 8.0
|
||||||
|
- Celery 5.4 (Redis broker)
|
||||||
|
- PySNMP 4.4.12
|
||||||
|
- Passlib + bcrypt (密码加密)
|
||||||
|
- python-jose (JWT token)
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- Vue 3.5
|
||||||
|
- Element Plus 2.9
|
||||||
|
- Vue Router 4.4
|
||||||
|
- Pinia 2.4 (状态管理)
|
||||||
|
- Axios 1.7
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 API 端点总览
|
||||||
|
|
||||||
|
### 认证 (/api/v1/auth)
|
||||||
|
- `POST /login` - 用户登录获取Token
|
||||||
|
- `POST /refresh` - 刷新访问令牌
|
||||||
|
- `POST /logout` - 登出
|
||||||
|
- `GET /me` - 获取当前用户信息
|
||||||
|
- `POST /change-password` - 修改密码
|
||||||
|
|
||||||
|
### 用户管理 (/api/v1/auth)
|
||||||
|
- `GET /users` - 用户列表
|
||||||
|
- `GET /users/{id}` - 用户详情
|
||||||
|
- `POST /users` - 创建用户
|
||||||
|
- `PUT /users/{id}/status` - 修改用户状态
|
||||||
|
- `POST /users/{id}/unlock` - 解锁用户
|
||||||
|
- `POST /users/{id}/reset-password` - 重置密码
|
||||||
|
- `DELETE /users/{id}` - 删除用户
|
||||||
|
|
||||||
|
### 网段管理 (/api/v1/networks)
|
||||||
|
- `GET /networks` - 网段列表
|
||||||
|
- `GET /networks/{id}` - 网段详情
|
||||||
|
- `POST /networks` - 创建网段
|
||||||
|
- `PUT /networks/{id}` - 更新网段
|
||||||
|
- `DELETE /networks/{id}` - 删除网段
|
||||||
|
- `GET /networks/{id}/ips` - 获取网段下的IP列表
|
||||||
|
- `POST /networks/{id}/scan` - 扫描网段
|
||||||
|
|
||||||
|
### IP地址管理 (/api/v1/ips)
|
||||||
|
- `GET /ips` - IP列表(支持筛选、搜索)
|
||||||
|
- `GET /ips/{id}` - IP详情
|
||||||
|
- `PUT /ips/{id}` - 更新IP信息
|
||||||
|
- `POST /ips/{id}/scan` - 扫描单个IP
|
||||||
|
|
||||||
|
### 告警管理 (/api/v1/alerts)
|
||||||
|
- `GET /alerts` - 告警列表
|
||||||
|
- `POST /alerts/detect/run` - 运行所有检测
|
||||||
|
- `POST /alerts/{id}/acknowledge` - 确认告警
|
||||||
|
- `POST /alerts/{id}/resolve` - 解决告警
|
||||||
|
- `POST /alerts/{id}/ignore` - 忽略告警
|
||||||
|
- `GET /alerts/statistics` - 告警统计
|
||||||
|
|
||||||
|
### MAC白名单 (/api/v1/alerts)
|
||||||
|
- `GET /whitelist/macs` - 获取白名单
|
||||||
|
- `POST /whitelist/macs` - 添加MAC到白名单
|
||||||
|
- `DELETE /whitelist/macs/{id}` - 从白名单移除
|
||||||
|
|
||||||
|
### SNMP管理 (/api/v1/snmp)
|
||||||
|
- `GET /credentials` - 凭据列表
|
||||||
|
- `POST /credentials` - 创建凭据
|
||||||
|
- `PUT /credentials/{id}` - 更新凭据
|
||||||
|
- `DELETE /credentials/{id}` - 删除凭据
|
||||||
|
- `GET /devices` - 设备列表
|
||||||
|
- `POST /devices` - 创建设备
|
||||||
|
- `POST /devices/{id}/test` - 测试连接
|
||||||
|
- `POST /devices/{id}/poll` - 立即轮询
|
||||||
|
- `GET /arp-entries` - ARP表
|
||||||
|
- `GET /statistics` - SNMP统计
|
||||||
|
|
||||||
|
### 审计日志 (/api/v1/audit)
|
||||||
|
- `GET /logs` - 审计日志列表
|
||||||
|
- `GET /logs/my` - 当前用户操作日志
|
||||||
|
- `GET /logs/resource/{type}/{id}` - 资源操作历史
|
||||||
|
- `GET /statistics` - 审计统计
|
||||||
|
- `DELETE /clean` - 清理旧日志
|
||||||
|
|
||||||
|
### 报表导出 (/api/v1/reports)
|
||||||
|
- `GET /ip-addresses/csv` - 导出IP地址CSV
|
||||||
|
- `GET /networks/csv` - 导出网段汇总CSV
|
||||||
|
- `GET /alerts/csv` - 导出告警CSV
|
||||||
|
- `GET /snmp-devices/csv` - 导出SNMP设备CSV
|
||||||
|
- `GET /arp-table/csv` - 导出ARP表CSV
|
||||||
|
- `GET /summary` - IPAM汇总统计报表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 角色权限矩阵
|
||||||
|
|
||||||
|
| 权限\角色 | viewer | operator | admin | super_admin |
|
||||||
|
|----------|--------|----------|-------|-------------|
|
||||||
|
| 查看所有数据 | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| 编辑IP信息 | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| 执行扫描 | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| 网段管理 | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| SNMP配置 | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| 导出报表 | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| 用户管理 | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
| 审计日志查看 | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| 系统设置 | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 部署启动
|
||||||
|
|
||||||
|
### 后端启动
|
||||||
|
```bash
|
||||||
|
cd /root/ipam/backend
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# 启动 API 服务
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8008
|
||||||
|
|
||||||
|
# 启动 Celery Worker
|
||||||
|
celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端启动
|
||||||
|
```bash
|
||||||
|
cd /root/ipam/frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 默认管理员
|
||||||
|
- 用户名: `admin`
|
||||||
|
- 密码: `admin123`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 数据库表结构
|
||||||
|
|
||||||
|
| 表名 | 说明 | 条数 |
|
||||||
|
|------|------|------|
|
||||||
|
| networks | 网段表 | 3 |
|
||||||
|
| ip_addresses | IP地址表 | 1530 |
|
||||||
|
| scan_tasks | 扫描任务表 | - |
|
||||||
|
| snmp_credentials | SNMP凭据表 | - |
|
||||||
|
| network_devices | SNMP设备表 | - |
|
||||||
|
| arp_entries | ARP记录表 | - |
|
||||||
|
| mac_address_table | MAC地址表 | - |
|
||||||
|
| device_interfaces | 设备接口表 | - |
|
||||||
|
| alerts | 告警表 | - |
|
||||||
|
| alert_rules | 告警规则表 | - |
|
||||||
|
| whitelisted_macs | MAC白名单表 | - |
|
||||||
|
| users | 用户表 | 1 |
|
||||||
|
| permissions | 权限表 | - |
|
||||||
|
| role_permissions | 角色权限关联表 | - |
|
||||||
|
| refresh_tokens | 刷新令牌表 | - |
|
||||||
|
| audit_logs | 审计日志表 | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 项目完成度
|
||||||
|
|
||||||
|
| 阶段 | 进度 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| P0 - 核心基础 | 100% | ✅ 完成 |
|
||||||
|
| P1 - 增强功能 | 100% | ✅ 完成 |
|
||||||
|
| P2 - 前端界面 | 100% | ✅ 完成 |
|
||||||
|
| P3 - 系统增强 | 100% | ✅ 完成 |
|
||||||
|
|
||||||
|
**总体完成度: 100%** 🎉
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Python 3.10 兼容性说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
本分支 (python3.10) 专门针对 Python 3.10 环境优化,确保所有代码和依赖都能在 Python 3.10 下正常运行。
|
||||||
|
|
||||||
|
## 兼容性调整
|
||||||
|
|
||||||
|
### 已验证的兼容特性
|
||||||
|
✅ 所有依赖包版本均支持 Python 3.10
|
||||||
|
✅ 无 Python 3.11 特有语法(如 `tomllib`、`asyncio.TaskGroup`、`typing.Self` 等)
|
||||||
|
✅ 类型注解完全兼容 Python 3.10
|
||||||
|
✅ `match` 关键字未用作 Python 3.10 的模式匹配语句(仅用作 `re.match()` 的变量名)
|
||||||
|
|
||||||
|
### 重要依赖说明
|
||||||
|
|
||||||
|
#### pysnmp 兼容问题解决
|
||||||
|
**问题**:`pysnmp==7.1.15` 存在以下兼容性问题:
|
||||||
|
- 使用已废弃的 `asyncio.coroutine` 装饰器(Python 3.11 中已移除)
|
||||||
|
- 与 Python 3.10+ 的 asyncio 不兼容
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
使用社区维护的分支 `pysnmp-lextudio==5.0.31`
|
||||||
|
- 完全向后兼容原有 pysnmp API
|
||||||
|
- 支持 Python 3.6+
|
||||||
|
- 积极维护,修复了 asyncio 兼容性问题
|
||||||
|
- 导入方式完全不变
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 无需修改代码,导入方式保持一致
|
||||||
|
from pysnmp.hlapi.asyncio import bulkCmd, getCmd, nextCmd
|
||||||
|
```
|
||||||
|
|
||||||
|
#### pyasn1 版本冲突解决
|
||||||
|
**问题**:`pyasn1>=0.5.0` 移除了 `pyasn1.compat.octets` 模块,导致 pysnmp 导入失败。
|
||||||
|
同时 `python-jose==3.5.0` 强制要求 `pyasn1>=0.5.0`,形成版本冲突。
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- `pyasn1==0.4.8` - 锁定到包含 compat.octets 的版本
|
||||||
|
- `pyasn1-modules==0.2.8` - 配套版本
|
||||||
|
- `python-jose[cryptography]==3.3.0` - 兼容 pyasn1 0.4.8 的版本
|
||||||
|
|
||||||
|
### 依赖版本要求
|
||||||
|
- `pysnmp-lextudio==5.0.31` - SNMP 协议兼容替代
|
||||||
|
- `typing-extensions>=4.5.0` - 提供额外的类型支持
|
||||||
|
- 所有核心依赖(FastAPI、SQLAlchemy、Pydantic 等)均已验证兼容
|
||||||
|
|
||||||
|
## 已知的 Python 3.11+ 特性未使用
|
||||||
|
项目中未使用以下 Python 3.11+ 特有的功能:
|
||||||
|
- `tomllib` 标准库(如有需要可使用 `tomli` 第三方库)
|
||||||
|
- `asyncio.TaskGroup`
|
||||||
|
- `typing.Self`
|
||||||
|
- `ExceptionGroup` / `except*`
|
||||||
|
- `StrEnum` / `IntEnum` 的新特性
|
||||||
|
|
||||||
|
## 安装说明
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 升级提示
|
||||||
|
如果未来需要升级到 Python 3.11+,可以:
|
||||||
|
1. 使用 `tomllib` 替代可能的 TOML 解析需求
|
||||||
|
2. 考虑使用 `asyncio.TaskGroup` 改进异步代码结构
|
||||||
|
3. 使用 `typing.Self` 简化类型注解
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
可以使用以下命令验证 Python 版本兼容性:
|
||||||
|
```bash
|
||||||
|
python --version # 应为 3.10.x 或 3.11.x
|
||||||
|
python -c "import sys; assert sys.version_info >= (3, 10), 'Python 3.10+ required'"
|
||||||
|
|
||||||
|
# 验证 pysnmp 导入
|
||||||
|
python -c "from pysnmp.hlapi.asyncio import bulkCmd, getCmd, nextCmd; print('pysnmp OK')"
|
||||||
|
```
|
||||||
@@ -1,277 +1,398 @@
|
|||||||
# IPAM 管理系统 - 完整功能
|
# IPAM 管理系统
|
||||||
|
|
||||||
企业级 IP 地址管理系统,支持自动化扫描、发现和管理网络 IP 资产。
|
企业级 IP 地址管理系统,支持自动化扫描、发现和管理网络 IP 资产。
|
||||||
|
|
||||||
## ✅ 已完成功能
|
---
|
||||||
|
|
||||||
### 🎯 P0 - 核心功能
|
## ✨ 功能特性
|
||||||
- ✅ **FastAPI 后端框架** - 高性能 RESTful API
|
|
||||||
- ✅ **MySQL 数据库** - 持久化存储
|
|
||||||
- ✅ **Redis 缓存** - 任务队列和缓存
|
|
||||||
- ✅ **网段管理** - CIDR 网段增删改查,自动创建 IP 记录
|
|
||||||
- ✅ **IP 资产台账** - IP 地址全生命周期管理
|
|
||||||
- ✅ **Ping 扫描引擎** - ICMP 在线状态检测
|
|
||||||
|
|
||||||
### 🚀 P1 - 高级功能
|
### 核心管理
|
||||||
- ✅ **ARP 扫描** - 获取 MAC 地址
|
- **网段管理** - CIDR 网段创建、分组、自动生成 IP 列表、使用率监控
|
||||||
- ✅ **反向 DNS 解析** - 获取主机名
|
- **IP 资产台账** - 在线/离线/空闲/保留状态管理、MAC地址、主机名、厂商识别、使用人/业务标注
|
||||||
- ✅ **MAC 厂商识别** - 内置 OUI 数据库
|
- **搜索筛选** - 按网段、状态、IP/MAC/主机名快速搜索
|
||||||
- ✅ **Celery 异步任务** - 后台并发扫描
|
|
||||||
- ✅ **SNMP 设备集成**
|
|
||||||
- SNMP v2c/v3 支持
|
|
||||||
- 设备连接测试
|
|
||||||
- ARP 表自动采集
|
|
||||||
- 接口信息采集
|
|
||||||
- ✅ **告警与异常检测**
|
|
||||||
- IP 冲突告警
|
|
||||||
- 未授权接入检测
|
|
||||||
- 网段耗尽预警
|
|
||||||
- 设备离线告警
|
|
||||||
- 新设备发现告警
|
|
||||||
- MAC 地址白名单
|
|
||||||
|
|
||||||
### 🎨 P2 - 前端界面
|
### 智能扫描
|
||||||
- ✅ **Vue 3 + Element Plus** - 现代化 UI 框架
|
- **ICMP Ping 扫描** - 快速检测在线状态
|
||||||
- ✅ **仪表盘** - 统计概览、网段使用率、告警统计
|
- **ARP 扫描** - 二层网络 MAC 地址发现
|
||||||
- ✅ **网段管理** - 网段 CRUD、扫描、导出
|
- **反向 DNS 解析** - 自动获取主机名
|
||||||
- ✅ **IP 地址管理** - 筛选、搜索、编辑
|
- **MAC 厂商识别** - 内置 IEEE OUI 数据库,识别 3000+ 设备厂商
|
||||||
- ✅ **扫描管理** - 综合扫描、结果导出
|
- **异步任务执行** - Celery 后台并发扫描,不阻塞 Web 服务
|
||||||
- ✅ **SNMP 管理** - 设备、凭据、ARP 表
|
|
||||||
- ✅ **告警中心** - 告警列表、统计、白名单
|
|
||||||
|
|
||||||
## 🚀 快速启动
|
### SNMP 网络设备集成
|
||||||
|
- **多版本支持** - SNMPv2c (Community)、SNMPv3 (认证+加密)
|
||||||
|
- **自动采集** - ARP 表、接口表、设备信息
|
||||||
|
- **设备状态** - 轮询状态监控、在线/离线检测
|
||||||
|
|
||||||
### 方式一:使用启动脚本(推荐)
|
### 告警与异常检测
|
||||||
|
- **IP 冲突告警** - 同一 IP 对应多个 MAC 地址
|
||||||
|
- **未授权接入检测** - 新接入设备不在白名单内自动告警
|
||||||
|
- **网段耗尽预警** - 网段使用率超阈值告警
|
||||||
|
- **设备离线告警** - 网络设备长时间不在线告警
|
||||||
|
- **MAC 白名单** - 可信设备免告警
|
||||||
|
|
||||||
|
### 用户与安全
|
||||||
|
- **JWT 身份认证** - Token 登录、自动刷新
|
||||||
|
- **4 级角色权限** - 只读 / 操作员 / 管理员 / 超级管理员
|
||||||
|
- **操作审计日志** - 全量操作记录、可追溯查询
|
||||||
|
- **密码加密存储** - bcrypt 加密
|
||||||
|
|
||||||
|
### 报表与导出
|
||||||
|
- **CSV 格式导出** - IP 地址表、网段汇总、告警记录、SNMP 设备、ARP 表
|
||||||
|
- **系统汇总报表** - IP 使用率、在线率、告警统计、网段分布
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
- Python 3.11+
|
||||||
|
- MySQL 8.0+
|
||||||
|
- Redis 6.0+
|
||||||
|
- Node.js 18+
|
||||||
|
|
||||||
|
### 一键启动所有服务
|
||||||
|
|
||||||
|
#### 方式一:使用启动脚本(推荐)
|
||||||
```bash
|
```bash
|
||||||
cd /root/ipam
|
cd /root/ipam
|
||||||
|
|
||||||
# 启动所有服务
|
|
||||||
chmod +x start.sh
|
|
||||||
./start.sh
|
./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### 方式二:手动启动
|
#### 方式二:手动启动
|
||||||
|
|
||||||
**1. 启动数据库和缓存**
|
|
||||||
|
#### 安装依赖环境
|
||||||
```bash
|
```bash
|
||||||
# MySQL 已运行在 3308 端口
|
cd /root/ipam/backend
|
||||||
# Redis 已运行在 6379 端口
|
source venv/bin/activate
|
||||||
|
pip install "pysnmp==4.4.12" "pyasn1<0.5.0" "pysmi<0.4.0"
|
||||||
|
apt install -y celery
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. 启动后端服务**
|
##### 1️⃣ 启动后端 API 服务
|
||||||
```bash
|
```bash
|
||||||
cd /root/ipam/backend
|
cd /root/ipam/backend
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8008
|
uvicorn app.main:app --host 0.0.0.0 --port 8008
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. 启动 Celery Worker**
|
##### 2️⃣ 启动 Celery Worker(执行扫描任务)
|
||||||
```bash
|
```bash
|
||||||
cd /root/ipam/backend
|
cd /root/ipam/backend
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
|
celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
|
||||||
```
|
```
|
||||||
|
|
||||||
**4. 启动前端服务**
|
##### 3️⃣ 启动 Celery Beat(定时任务调度器,自动扫描必须启动)
|
||||||
|
```bash
|
||||||
|
cd /root/ipam/backend
|
||||||
|
source venv/bin/activate
|
||||||
|
celery -A app.tasks.celery_app beat --loglevel=info
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 4️⃣ 启动前端
|
||||||
```bash
|
```bash
|
||||||
cd /root/ipam/frontend
|
cd /root/ipam/frontend
|
||||||
|
npm install
|
||||||
npm run dev -- --host 0.0.0.0 --port 3000
|
npm run dev -- --host 0.0.0.0 --port 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏰ 自动扫描配置
|
||||||
|
|
||||||
|
### 扫描频率配置
|
||||||
|
|
||||||
|
系统默认配置了 **3 个定时扫描任务**:
|
||||||
|
|
||||||
|
| 任务名称 | 执行频率 | 扫描内容 | 说明 |
|
||||||
|
|----------|----------|----------|------|
|
||||||
|
| **每小时全量扫描** | 每 60 分钟 | Ping + ARP + DNS | 快速刷新在线状态 |
|
||||||
|
| **每日深度扫描** | 每天凌晨 02:00 | Ping + ARP + DNS | 每日完整扫描更新 |
|
||||||
|
| **统计更新** | 每 15 分钟 | 仅更新统计 | 更新网段使用率 |
|
||||||
|
|
||||||
|
### 如何自定义扫描频率
|
||||||
|
|
||||||
|
编辑配置文件:`/root/ipam/backend/app/tasks/celery_app.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 定时任务配置 (第 36-55 行)
|
||||||
|
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), # 修改 hour/minute 调整时间
|
||||||
|
'args': (True, True, True)
|
||||||
|
},
|
||||||
|
# 每15分钟更新统计
|
||||||
|
'update-stats-every-15min': {
|
||||||
|
'task': 'app.tasks.scan_tasks.update_all_statistics',
|
||||||
|
'schedule': 900.0, # 单位:秒
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**常用配置示例:**
|
||||||
|
```python
|
||||||
|
# 每 30 分钟扫描一次
|
||||||
|
'schedule': 1800.0
|
||||||
|
|
||||||
|
# 每天凌晨 3:30 扫描
|
||||||
|
'schedule': crontab(hour=3, minute=30)
|
||||||
|
|
||||||
|
# 工作日上午 9 点扫描
|
||||||
|
'schedule': crontab(hour=9, minute=0, day_of_week='mon-fri')
|
||||||
|
|
||||||
|
# 仅扫描 Ping,不扫描 ARP 和 DNS
|
||||||
|
'args': (True, False, False)
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ 注意:修改配置后必须重启 Celery Worker 和 Celery Beat 才会生效!**
|
||||||
|
|
||||||
|
### 扫描任务包含的内容
|
||||||
|
|
||||||
|
每次完整扫描会执行以下操作:
|
||||||
|
1. **ICMP Ping** - 检测 IP 是否在线
|
||||||
|
2. **ARP 扫描** - 获取 MAC 地址(同网段有效)
|
||||||
|
3. **反向 DNS** - 解析主机名
|
||||||
|
4. **MAC 厂商识别** - 根据 MAC 地址前 3 字节识别厂商
|
||||||
|
5. **自动更新** - 更新 IP 状态、MAC、主机名、最后发现时间
|
||||||
|
6. **异常检测** - 触发 IP 冲突、未授权接入等告警检查
|
||||||
|
|
||||||
|
### 监控定时任务状态
|
||||||
|
|
||||||
|
#### 查看 Celery 活跃任务
|
||||||
|
```bash
|
||||||
|
cd /root/ipam/backend
|
||||||
|
source venv/bin/activate
|
||||||
|
celery -A app.tasks.celery_app inspect active
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 查看定时任务调度
|
||||||
|
```bash
|
||||||
|
celery -A app.tasks.celery_app inspect scheduled
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 查看 Worker 状态
|
||||||
|
```bash
|
||||||
|
celery -A app.tasks.celery_app inspect stats
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 生产环境部署建议
|
||||||
|
|
||||||
|
### 使用 Systemd 管理服务(开机自启)
|
||||||
|
|
||||||
|
#### 1. 创建 IPAM API 服务
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/ipam-api.service
|
||||||
|
[Unit]
|
||||||
|
Description=IPAM API Service
|
||||||
|
After=network.target mysql.service redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/root/ipam/backend
|
||||||
|
Environment="PATH=/root/ipam/backend/venv/bin"
|
||||||
|
ExecStart=/root/ipam/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8008
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 创建 Celery Worker 服务
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/ipam-celery-worker.service
|
||||||
|
[Unit]
|
||||||
|
Description=IPAM Celery Worker
|
||||||
|
After=network.target redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/root/ipam/backend
|
||||||
|
Environment="PATH=/root/ipam/backend/venv/bin"
|
||||||
|
ExecStart=/root/ipam/backend/venv/bin/celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 创建 Celery Beat 服务
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/ipam-celery-beat.service
|
||||||
|
[Unit]
|
||||||
|
Description=IPAM Celery Beat Scheduler
|
||||||
|
After=network.target redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/root/ipam/backend
|
||||||
|
Environment="PATH=/root/ipam/backend/venv/bin"
|
||||||
|
ExecStart=/root/ipam/backend/venv/bin/celery -A app.tasks.celery_app beat --loglevel=info
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 启用并启动服务
|
||||||
|
```bash
|
||||||
|
# 重新加载配置
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# 启用开机自启
|
||||||
|
systemctl enable ipam-api ipam-celery-worker ipam-celery-beat
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
systemctl start ipam-api ipam-celery-worker ipam-celery-beat
|
||||||
|
|
||||||
|
# 查看服务状态
|
||||||
|
systemctl status ipam-api ipam-celery-worker ipam-celery-beat
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🌐 访问地址
|
## 🌐 访问地址
|
||||||
|
|
||||||
| 服务 | 地址 | 说明 |
|
| 服务 | 地址 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 前端界面 | http://服务器IP:3000 | Web 管理界面 |
|
| 管理后台 | `http://服务器IP:3000` | Vue 前端界面 |
|
||||||
| 后端 API | http://服务器IP:8008 | API 服务 |
|
| API 服务 | `http://服务器IP:8008` | REST API |
|
||||||
| API 文档 | http://服务器IP:8008/docs | Swagger 在线文档 |
|
| Swagger 文档 | `http://服务器IP:8008/docs` | API 在线测试文档 |
|
||||||
| MySQL | 服务器IP:3308 | 数据库 |
|
| Redoc 文档 | `http://服务器IP:8008/redoc` | API 文档 |
|
||||||
| Redis | 服务器IP:6379 | 缓存和任务队列 |
|
|
||||||
|
|
||||||
## 📖 API 使用示例
|
**默认管理员账号:**
|
||||||
|
- 用户名: `admin`
|
||||||
### 1. 创建网段
|
- 密码: `admin123`
|
||||||
```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
|
|
||||||
|
| 组件 | 技术选型 | 版本 |
|
||||||
|
|------|----------|------|
|
||||||
|
| Web 框架 | FastAPI | 0.115 |
|
||||||
|
| ORM | SQLAlchemy | 2.0 |
|
||||||
|
| 数据库 | MySQL | 8.0 |
|
||||||
|
| 异步任务 | Celery + Redis | 5.4 |
|
||||||
|
| SNMP | PySNMP | 4.4.12 |
|
||||||
|
| 密码加密 | Passlib + bcrypt | - |
|
||||||
|
| JWT 认证 | python-jose | - |
|
||||||
|
| 前端框架 | Vue 3 + Element Plus | 3.5 / 2.9 |
|
||||||
|
| 路由 | Vue Router | 4.4 |
|
||||||
|
| 状态管理 | Pinia | 2.4 |
|
||||||
|
| HTTP 客户端 | Axios | 1.7 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 角色权限矩阵
|
||||||
|
|
||||||
|
| 操作 | Viewer (只读) | Operator (操作员) | Admin (管理员) | Super Admin (超级管理员) |
|
||||||
|
|------|---------------|------------------|----------------|---------------------|
|
||||||
|
| 查看所有数据 | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| 编辑 IP 信息 | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| 执行扫描 | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| 确认/解决告警 | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| 网段管理 | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| SNMP 设备/凭据 | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| MAC 白名单 | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| 导出报表 | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| 查看审计日志 | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| 用户管理 | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
| 系统设置 | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 API 端点总览
|
||||||
|
|
||||||
|
| 模块 | 端点数量 | 主要功能 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 认证 | 10 | 登录、登出、Token刷新、用户管理 |
|
||||||
|
| 网段管理 | 7 | CRUD、扫描、IP列表、统计 |
|
||||||
|
| IP 地址 | 5 | 列表、详情、编辑、扫描 |
|
||||||
|
| 扫描 | 8 | 单IP、网段、异步任务、状态查询 |
|
||||||
|
| SNMP | 13 | 凭据、设备、轮询、ARP表、接口 |
|
||||||
|
| 告警 | 11 | 列表、确认/解决/忽略、检测、白名单 |
|
||||||
|
| 审计日志 | 5 | 日志查询、用户日志、资源历史、统计、清理 |
|
||||||
|
| 报表 | 6 | CSV导出、汇总统计 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 常见问题
|
||||||
|
|
||||||
|
### Q: 为什么 IP 扫描后 MAC 地址都是空的?
|
||||||
|
A: ARP 扫描只能获取**同网段**设备的 MAC 地址。跨网段需要配置 SNMP 采集核心交换机的 ARP 表。
|
||||||
|
|
||||||
|
### Q: 定时扫描没有执行?
|
||||||
|
A: 请检查 **Celery Beat** 是否正常启动,Celery Worker 是否正常运行,Redis 连接是否正常。
|
||||||
|
|
||||||
|
### Q: 如何临时禁用自动扫描?
|
||||||
|
A: 停止 Celery Beat 服务即可,不会影响手动触发扫描。
|
||||||
|
|
||||||
|
### Q: 如何调整扫描并发数?
|
||||||
|
A: 启动 Celery Worker 时调整 `--concurrency` 参数:
|
||||||
|
```bash
|
||||||
|
celery -A app.tasks.celery_app worker --concurrency=8 # 8并发
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 扫描时间太长怎么办?
|
||||||
|
A: 1. 增加并发数 2. 关闭 DNS 解析(较慢)3. 分网段分时段扫描
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/root/ipam/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── api/v1/ # API 路由
|
||||||
|
│ │ ├── core/ # 配置、数据库、安全
|
||||||
|
│ │ ├── models/ # 数据模型
|
||||||
|
│ │ ├── schemas/ # Pydantic 验证模型
|
||||||
|
│ │ ├── services/ # 业务逻辑层
|
||||||
|
│ │ ├── tasks/ # Celery 异步任务
|
||||||
|
│ │ └── main.py # 应用入口
|
||||||
|
│ └── venv/ # Python 虚拟环境
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── views/ # 页面组件
|
||||||
|
│ │ ├── components/ # 通用组件
|
||||||
|
│ │ ├── router/ # 路由配置
|
||||||
|
│ │ └── main.js # 应用入口
|
||||||
|
│ └── node_modules/
|
||||||
|
├── start.sh # 一键启动脚本
|
||||||
|
├── stop.sh # 停止服务脚本
|
||||||
|
├── DEV_PROGRESS.md # 开发进度文档
|
||||||
|
└── README.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 支持
|
||||||
|
|
||||||
|
如遇问题,请查看:
|
||||||
|
1. API 文档:`http://<服务器IP>:8008/docs`
|
||||||
|
2. 开发进度:`DEV_PROGRESS.md`
|
||||||
|
3. Celery 日志:查看 Worker 和 Beat 终端输出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.v1 import networks, ips, scan, enhanced_scan, async_scan, snmp, alerts, auth, audit
|
from app.api.v1 import networks, ips, scan, enhanced_scan, async_scan, snmp, alerts, auth, audit, reports
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -12,3 +12,4 @@ api_router.include_router(snmp.router)
|
|||||||
api_router.include_router(alerts.router)
|
api_router.include_router(alerts.router)
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(audit.router)
|
api_router.include_router(audit.router)
|
||||||
|
api_router.include_router(reports.router)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,7 @@ from typing import Optional
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.services.alert_service import AlertService
|
from app.services.alert_service import AlertService
|
||||||
|
from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list, to_business_iso
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/alerts",
|
prefix="/alerts",
|
||||||
@@ -37,7 +38,7 @@ def get_alerts(
|
|||||||
total = query.count()
|
total = query.count()
|
||||||
items = query.order_by(Alert.created_at.desc()).offset(skip).limit(limit).all()
|
items = query.order_by(Alert.created_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
return {"total": total, "items": items}
|
return {"total": total, "items": serialize_dt_list(items)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{alert_id}", summary="获取告警详情")
|
@router.get("/{alert_id}", summary="获取告警详情")
|
||||||
@@ -47,7 +48,7 @@ def get_alert(alert_id: int, db: Session = Depends(get_db)):
|
|||||||
alert = db.query(Alert).filter(Alert.id == alert_id).first()
|
alert = db.query(Alert).filter(Alert.id == alert_id).first()
|
||||||
if not alert:
|
if not alert:
|
||||||
raise HTTPException(status_code=404, detail="告警不存在")
|
raise HTTPException(status_code=404, detail="告警不存在")
|
||||||
return alert
|
return serialize_dt_fields(alert)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{alert_id}/acknowledge", summary="确认告警")
|
@router.post("/{alert_id}/acknowledge", summary="确认告警")
|
||||||
|
|||||||
+129
-127
@@ -1,164 +1,166 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user, require_permission
|
||||||
from app.models.auth import User
|
from app.models.auth import User
|
||||||
|
from app.models.audit import AuditAction, AuditResource
|
||||||
from app.services.audit_service import AuditService
|
from app.services.audit_service import AuditService
|
||||||
|
from app.schemas._tz_util import to_business_iso
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(prefix="/audit", tags=["审计日志"])
|
||||||
prefix="/audit",
|
|
||||||
tags=["审计日志"],
|
|
||||||
dependencies=[Depends(get_current_user)],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logs", summary="查询审计日志")
|
@router.get("/logs", summary="获取审计日志列表")
|
||||||
def get_audit_logs(
|
def get_audit_logs(
|
||||||
user_id: Optional[int] = None,
|
user_id: Optional[int] = None,
|
||||||
username: Optional[str] = None,
|
action: Optional[AuditAction] = None,
|
||||||
action: Optional[str] = None,
|
resource: Optional[AuditResource] = None,
|
||||||
resource_type: Optional[str] = None,
|
success: Optional[bool] = None,
|
||||||
status: Optional[str] = None,
|
|
||||||
start_time: Optional[datetime] = None,
|
start_time: Optional[datetime] = None,
|
||||||
end_time: Optional[datetime] = None,
|
end_time: Optional[datetime] = None,
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=500),
|
limit: int = Query(100, ge=1, le=500),
|
||||||
# 当前用户必须是 admin 或 super_admin 才能看审计
|
current_user: User = Depends(require_permission("audit:view")),
|
||||||
current_user: User = Depends(get_current_user),
|
db: Session = Depends(get_db)
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
):
|
||||||
# 角色检查:super_admin / admin 可以看所有人的,operator/viewer 只能看自己的
|
"""
|
||||||
if current_user.role not in ("super_admin", "admin"):
|
获取审计日志列表(需要管理员权限)
|
||||||
user_id = current_user.id
|
|
||||||
|
- **user_id**: 按用户ID过滤
|
||||||
|
- **action**: 按操作类型过滤 (create/read/update/delete/login/logout/scan/export)
|
||||||
|
- **resource**: 按资源类型过滤
|
||||||
|
- **success**: 按操作结果过滤
|
||||||
|
- **start_time**: 开始时间
|
||||||
|
- **end_time**: 结束时间
|
||||||
|
"""
|
||||||
total, items = AuditService.get_logs(
|
total, items = AuditService.get_logs(
|
||||||
db,
|
db,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
username=username,
|
|
||||||
action=action,
|
action=action,
|
||||||
resource_type=resource_type,
|
resource=resource,
|
||||||
status=status,
|
success=success,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
limit=limit,
|
limit=limit
|
||||||
)
|
)
|
||||||
|
|
||||||
serialized = []
|
# 格式化返回
|
||||||
for log in items:
|
result_items = []
|
||||||
serialized.append({
|
for item in items:
|
||||||
"id": log.id,
|
changed = item.changed_fields.split(',') if item.changed_fields else []
|
||||||
"user_id": log.user_id,
|
result_items.append({
|
||||||
"username": log.username,
|
"id": item.id,
|
||||||
"action": log.action,
|
"user_id": item.user_id,
|
||||||
"resource_type": log.resource_type,
|
"username": item.username,
|
||||||
"resource_id": log.resource_id,
|
"real_name": item.real_name,
|
||||||
"resource_name": log.resource_name,
|
"user_ip": item.user_ip,
|
||||||
"method": log.method,
|
"action": item.action.value,
|
||||||
"path": log.path,
|
"resource": item.resource.value,
|
||||||
"ip_address": log.ip_address,
|
"resource_id": item.resource_id,
|
||||||
"user_agent": log.user_agent,
|
"description": item.description,
|
||||||
"status": log.status,
|
"changed_fields": changed,
|
||||||
"detail": log.detail,
|
"request_method": item.request_method,
|
||||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
"request_path": item.request_path,
|
||||||
|
"success": bool(item.success),
|
||||||
|
"error_message": item.error_message,
|
||||||
|
"created_at": to_business_iso(item.created_at)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"total": total, "items": serialized}
|
return {"total": total, "items": result_items}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logs/export", summary="导出审计日志为 CSV")
|
@router.get("/logs/my", summary="获取当前用户的操作日志")
|
||||||
def export_audit_logs(
|
def get_my_logs(
|
||||||
user_id: Optional[int] = None,
|
skip: int = Query(0, ge=0),
|
||||||
username: Optional[str] = None,
|
limit: int = Query(50, ge=1, le=200),
|
||||||
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),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
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(
|
total, items = AuditService.get_logs(
|
||||||
db,
|
db,
|
||||||
user_id=user_id,
|
user_id=current_user.id,
|
||||||
username=username,
|
skip=skip,
|
||||||
action=action,
|
limit=limit
|
||||||
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}"'},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result_items = []
|
||||||
|
for item in items:
|
||||||
|
result_items.append({
|
||||||
|
"id": item.id,
|
||||||
|
"action": item.action.value,
|
||||||
|
"resource": item.resource.value,
|
||||||
|
"resource_id": item.resource_id,
|
||||||
|
"description": item.description,
|
||||||
|
"success": bool(item.success),
|
||||||
|
"created_at": item.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"total": total, "items": result_items}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", summary="审计日志统计")
|
@router.get("/logs/resource/{resource_type}/{resource_id}", summary="获取资源操作历史")
|
||||||
def get_audit_stats(
|
def get_resource_logs(
|
||||||
current_user: User = Depends(get_current_user),
|
resource_type: str,
|
||||||
db: Session = Depends(get_db),
|
resource_id: str,
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
current_user: User = Depends(require_permission("audit:view")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
if current_user.role not in ("super_admin", "admin"):
|
"""获取指定资源的操作历史"""
|
||||||
# operator/viewer 只能看自己的简单计数
|
try:
|
||||||
from app.models.audit import AuditLog
|
resource_enum = AuditResource(resource_type)
|
||||||
from sqlalchemy import func
|
except ValueError:
|
||||||
my_count = db.query(func.count(AuditLog.id)).filter(AuditLog.user_id == current_user.id).scalar()
|
return {"total": 0, "items": [], "error": "无效的资源类型"}
|
||||||
return {"recent_24h": 0, "by_action_24h": {}, "my_total": int(my_count or 0)}
|
|
||||||
|
items = AuditService.get_resource_logs(db, resource_enum, resource_id, limit=limit)
|
||||||
return AuditService.get_stats(db)
|
|
||||||
|
result_items = []
|
||||||
|
for item in items:
|
||||||
|
changed = item.changed_fields.split(',') if item.changed_fields else []
|
||||||
|
result_items.append({
|
||||||
|
"id": item.id,
|
||||||
|
"username": item.username,
|
||||||
|
"real_name": item.real_name,
|
||||||
|
"action": item.action.value,
|
||||||
|
"description": item.description,
|
||||||
|
"changed_fields": changed,
|
||||||
|
"success": bool(item.success),
|
||||||
|
"created_at": item.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"total": len(items), "items": result_items}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/actions", summary="获取所有审计操作类型(用于前端过滤下拉框)")
|
@router.get("/statistics", summary="获取审计统计信息")
|
||||||
def get_action_types():
|
def get_audit_statistics(
|
||||||
"""硬编码返回常用 action 列表,避免每次都查 DB"""
|
days: int = Query(30, ge=1, le=365),
|
||||||
from app.services.audit_service import AuditAction
|
current_user: User = Depends(require_permission("audit:view")),
|
||||||
actions = []
|
db: Session = Depends(get_db)
|
||||||
for name in dir(AuditAction):
|
):
|
||||||
if name.startswith("_") or name.isupper() and not name.startswith("__"):
|
"""获取审计统计信息"""
|
||||||
continue
|
stats = AuditService.get_statistics(db, days=days)
|
||||||
val = getattr(AuditAction, name, None)
|
return stats
|
||||||
if isinstance(val, str):
|
|
||||||
actions.append({"code": val, "name": name})
|
|
||||||
return actions
|
@router.delete("/clean", summary="清理旧日志")
|
||||||
|
def clean_old_logs(
|
||||||
|
days: int = Query(90, ge=7, le=3650, description="保留天数"),
|
||||||
|
current_user: User = Depends(require_permission("system:admin")),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
清理指定天数之前的审计日志(需要超级管理员权限)
|
||||||
|
|
||||||
|
- **days**: 保留日志的天数,默认90天
|
||||||
|
"""
|
||||||
|
deleted_count = AuditService.clean_old_logs(db, days=days)
|
||||||
|
return {
|
||||||
|
"message": f"清理完成,已删除 {deleted_count} 条旧日志",
|
||||||
|
"deleted_count": deleted_count,
|
||||||
|
"kept_days": days
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ def comprehensive_scan(
|
|||||||
enable_ping: bool = True,
|
enable_ping: bool = True,
|
||||||
enable_arp: bool = True,
|
enable_arp: bool = True,
|
||||||
enable_dns: bool = True,
|
enable_dns: bool = True,
|
||||||
|
enable_netbios: bool = True,
|
||||||
timeout: int = 2,
|
timeout: int = 2,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -32,7 +33,8 @@ def comprehensive_scan(
|
|||||||
- Ping 扫描检测在线状态
|
- Ping 扫描检测在线状态
|
||||||
- ARP 扫描获取MAC地址
|
- ARP 扫描获取MAC地址
|
||||||
- 反向DNS解析获取主机名
|
- 反向DNS解析获取主机名
|
||||||
- MAC厂商识别
|
- NetBIOS 广播获取主机名(Windows/SMB 设备)
|
||||||
|
- MAC 厂商识别(IEEE OUI 数据库)
|
||||||
"""
|
"""
|
||||||
network = NetworkService.get_by_id(db, network_id)
|
network = NetworkService.get_by_id(db, network_id)
|
||||||
if not network:
|
if not network:
|
||||||
@@ -51,6 +53,7 @@ def comprehensive_scan(
|
|||||||
enable_ping=enable_ping,
|
enable_ping=enable_ping,
|
||||||
enable_arp=enable_arp,
|
enable_arp=enable_arp,
|
||||||
enable_dns=enable_dns,
|
enable_dns=enable_dns,
|
||||||
|
enable_netbios=enable_netbios,
|
||||||
timeout=timeout
|
timeout=timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ def comprehensive_scan_ip(
|
|||||||
enable_ping: bool = True,
|
enable_ping: bool = True,
|
||||||
enable_arp: bool = True,
|
enable_arp: bool = True,
|
||||||
enable_dns: bool = True,
|
enable_dns: bool = True,
|
||||||
|
enable_netbios: bool = True,
|
||||||
timeout: int = 2,
|
timeout: int = 2,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -101,6 +105,7 @@ def comprehensive_scan_ip(
|
|||||||
enable_ping=enable_ping,
|
enable_ping=enable_ping,
|
||||||
enable_arp=enable_arp,
|
enable_arp=enable_arp,
|
||||||
enable_dns=enable_dns,
|
enable_dns=enable_dns,
|
||||||
|
enable_netbios=enable_netbios,
|
||||||
timeout=timeout
|
timeout=timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,6 +145,93 @@ def reverse_dns(ip_address: str, timeout: int = 2):
|
|||||||
"hostname": hostname or "Unknown"
|
"hostname": hostname or "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.post("/netbios/lookup/{ip_address}", summary="NetBIOS 主机名发现")
|
||||||
|
def netbios_lookup(ip_address: str, timeout: int = 3):
|
||||||
|
"""
|
||||||
|
通过 NetBIOS 广播协议获取主机名(Windows / SMB 设备)
|
||||||
|
"""
|
||||||
|
hostname = EnhancedScanService.netbios_lookup(ip_address, timeout)
|
||||||
|
return {
|
||||||
|
"ip_address": ip_address,
|
||||||
|
"hostname": hostname or "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/batch/vendor", summary="批量补全厂商信息")
|
||||||
|
def batch_fill_vendor(
|
||||||
|
network_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
对已有 MAC 地址但缺少厂商信息的 IP 批量补全厂商(使用 IEEE OUI 数据库)
|
||||||
|
"""
|
||||||
|
from app.models.network import IPAddress as IPAddressModel
|
||||||
|
|
||||||
|
query = db.query(IPAddressModel).filter(
|
||||||
|
IPAddressModel.mac_address.isnot(None),
|
||||||
|
IPAddressModel.vendor.is_(None)
|
||||||
|
)
|
||||||
|
if network_id:
|
||||||
|
query = query.filter(IPAddressModel.network_id == network_id)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for ip in items:
|
||||||
|
vendor = EnhancedScanService.get_mac_vendor(ip.mac_address)
|
||||||
|
if vendor:
|
||||||
|
ip.vendor = vendor
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
if updated_count > 0:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"已补全 {updated_count} 条厂商信息",
|
||||||
|
"checked": len(items),
|
||||||
|
"updated": updated_count
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/batch/hostname", summary="批量发现主机名")
|
||||||
|
def batch_discover_hostname(
|
||||||
|
network_id: Optional[int] = None,
|
||||||
|
enable_dns: bool = True,
|
||||||
|
enable_netbios: bool = True,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
对已有 MAC 地址但缺少主机名的 IP 批量执行主机名发现(DNS PTR + NetBIOS)
|
||||||
|
"""
|
||||||
|
from app.models.network import IPAddress as IPAddressModel
|
||||||
|
|
||||||
|
query = db.query(IPAddressModel).filter(
|
||||||
|
IPAddressModel.mac_address.isnot(None),
|
||||||
|
IPAddressModel.hostname.is_(None)
|
||||||
|
)
|
||||||
|
if network_id:
|
||||||
|
query = query.filter(IPAddressModel.network_id == network_id)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for ip in items:
|
||||||
|
hostname = None
|
||||||
|
if enable_dns:
|
||||||
|
hostname = EnhancedScanService.reverse_dns_lookup(ip.ip_address, timeout=2)
|
||||||
|
if not hostname and enable_netbios:
|
||||||
|
hostname = EnhancedScanService.netbios_lookup(ip.ip_address, timeout=3)
|
||||||
|
if hostname:
|
||||||
|
ip.hostname = hostname
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
if updated_count > 0:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"已发现 {updated_count} 条主机名",
|
||||||
|
"checked": len(items),
|
||||||
|
"updated": updated_count
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/online/ips", summary="获取所有在线IP列表")
|
@router.get("/online/ips", summary="获取所有在线IP列表")
|
||||||
def get_online_ips(
|
def get_online_ips(
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import require_permission
|
||||||
|
from app.models.network import IPStatus
|
||||||
|
from app.services.report_service import ReportExportService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/reports", tags=["报表导出"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ip-addresses/csv", summary="导出IP地址CSV")
|
||||||
|
def export_ip_addresses_csv(
|
||||||
|
network_id: Optional[int] = None,
|
||||||
|
status: Optional[IPStatus] = None,
|
||||||
|
online_only: bool = Query(False, description="仅导出在线IP"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_permission("report:export"))
|
||||||
|
):
|
||||||
|
"""导出IP地址表CSV"""
|
||||||
|
csv_content, filename = ReportExportService.export_ip_addresses_csv(
|
||||||
|
db,
|
||||||
|
network_id=network_id,
|
||||||
|
status=status,
|
||||||
|
include_online_only=online_only
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=csv_content,
|
||||||
|
media_type="text/csv; charset=utf-8-sig",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/networks/csv", summary="导出网段汇总CSV")
|
||||||
|
def export_networks_csv(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_permission("report:export"))
|
||||||
|
):
|
||||||
|
"""导出网段汇总CSV"""
|
||||||
|
csv_content, filename = ReportExportService.export_networks_csv(db)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=csv_content,
|
||||||
|
media_type="text/csv; charset=utf-8-sig",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/alerts/csv", summary="导出告警CSV")
|
||||||
|
def export_alerts_csv(
|
||||||
|
status: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_permission("report:export"))
|
||||||
|
):
|
||||||
|
"""导出告警CSV"""
|
||||||
|
csv_content, filename = ReportExportService.export_alerts_csv(db, status=status)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=csv_content,
|
||||||
|
media_type="text/csv; charset=utf-8-sig",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/snmp-devices/csv", summary="导出SNMP设备CSV")
|
||||||
|
def export_snmp_devices_csv(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_permission("report:export"))
|
||||||
|
):
|
||||||
|
"""导出SNMP设备CSV"""
|
||||||
|
csv_content, filename = ReportExportService.export_snmp_devices_csv(db)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=csv_content,
|
||||||
|
media_type="text/csv; charset=utf-8-sig",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/arp-table/csv", summary="导出ARP表CSV")
|
||||||
|
def export_arp_table_csv(
|
||||||
|
device_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_permission("report:export"))
|
||||||
|
):
|
||||||
|
"""导出ARP表CSV"""
|
||||||
|
csv_content, filename = ReportExportService.export_arp_table_csv(db, device_id=device_id)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=csv_content,
|
||||||
|
media_type="text/csv; charset=utf-8-sig",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", summary="获取IPAM系统汇总报表")
|
||||||
|
def get_ipam_summary_report(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user = Depends(require_permission("report:view"))
|
||||||
|
):
|
||||||
|
"""获取IPAM系统汇总统计报表"""
|
||||||
|
summary = ReportExportService.get_ipam_summary(db)
|
||||||
|
return summary
|
||||||
+36
-41
@@ -7,6 +7,7 @@ from app.core.security import get_current_user
|
|||||||
from app.models.auth import User
|
from app.models.auth import User
|
||||||
from app.services.snmp_service import SNMPService
|
from app.services.snmp_service import SNMPService
|
||||||
from app.services.audit_service import AuditService, AuditAction
|
from app.services.audit_service import AuditService, AuditAction
|
||||||
|
from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/snmp",
|
prefix="/snmp",
|
||||||
@@ -34,7 +35,7 @@ def get_snmp_credentials(
|
|||||||
query = db.query(SNMPCredential)
|
query = db.query(SNMPCredential)
|
||||||
total = query.count()
|
total = query.count()
|
||||||
items = query.order_by(SNMPCredential.id.desc()).offset(skip).limit(limit).all()
|
items = query.order_by(SNMPCredential.id.desc()).offset(skip).limit(limit).all()
|
||||||
return {"total": total, "items": items}
|
return {"total": total, "items": serialize_dt_list(items)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/credentials/{credential_id}", summary="获取凭据详情")
|
@router.get("/credentials/{credential_id}", summary="获取凭据详情")
|
||||||
@@ -43,7 +44,7 @@ def get_snmp_credential(credential_id: int, db: Session = Depends(get_db)):
|
|||||||
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
|
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
|
||||||
if not credential:
|
if not credential:
|
||||||
raise HTTPException(status_code=404, detail="凭据不存在")
|
raise HTTPException(status_code=404, detail="凭据不存在")
|
||||||
return credential
|
return serialize_dt_fields(credential)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/credentials", summary="创建 SNMP 凭据")
|
@router.post("/credentials", summary="创建 SNMP 凭据")
|
||||||
@@ -100,7 +101,7 @@ def create_snmp_credential(
|
|||||||
user_agent=u,
|
user_agent=u,
|
||||||
detail={"version": credential.version, "name": credential.name},
|
detail={"version": credential.version, "name": credential.name},
|
||||||
)
|
)
|
||||||
return credential
|
return serialize_dt_fields(credential)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/credentials/{credential_id}", summary="更新 SNMP 凭据")
|
@router.put("/credentials/{credential_id}", summary="更新 SNMP 凭据")
|
||||||
@@ -172,7 +173,7 @@ def update_snmp_credential(
|
|||||||
"after": {"name": credential.name, "description": credential.description, "is_active": credential.is_active, "timeout": credential.timeout, "retries": credential.retries},
|
"after": {"name": credential.name, "description": credential.description, "is_active": credential.is_active, "timeout": credential.timeout, "retries": credential.retries},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return credential
|
return serialize_dt_fields(credential)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/credentials/{credential_id}", summary="删除 SNMP 凭据")
|
@router.delete("/credentials/{credential_id}", summary="删除 SNMP 凭据")
|
||||||
@@ -243,29 +244,16 @@ def get_network_devices(
|
|||||||
|
|
||||||
serialized = []
|
serialized = []
|
||||||
for d in items:
|
for d in items:
|
||||||
item = {
|
item = serialize_dt_fields(d)
|
||||||
"id": d.id,
|
# 补充手写的关联字段(datetime 已经由 serialize_dt_fields 序列化)
|
||||||
"name": d.name,
|
item["device_type"] = d.device_type.value if hasattr(d.device_type, 'value') else d.device_type
|
||||||
"description": d.description,
|
item["credential_name"] = cred_map.get(d.snmp_credential_id)
|
||||||
"ip_address": d.ip_address,
|
item["snmp_credential_id"] = d.snmp_credential_id # 显式添加,确保前端能拿到
|
||||||
"port": d.port,
|
item["last_polled_at"] = item.get("last_polled_at") # 已是 ISO 字符串
|
||||||
"device_type": d.device_type.value if hasattr(d.device_type, 'value') else d.device_type,
|
item["last_successful_poll"] = item.get("last_successful_poll")
|
||||||
"vendor": d.vendor,
|
item["arp_poll_interval"] = d.arp_poll_interval
|
||||||
"model": d.model,
|
item["mac_poll_interval"] = d.mac_poll_interval
|
||||||
"firmware_version": d.firmware_version,
|
item["interface_poll_interval"] = d.interface_poll_interval
|
||||||
"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)
|
serialized.append(item)
|
||||||
|
|
||||||
return {"total": total, "items": serialized}
|
return {"total": total, "items": serialized}
|
||||||
@@ -278,14 +266,14 @@ def get_network_device(device_id: int, db: Session = Depends(get_db)):
|
|||||||
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
||||||
if not device:
|
if not device:
|
||||||
raise HTTPException(status_code=404, detail="设备不存在")
|
raise HTTPException(status_code=404, detail="设备不存在")
|
||||||
return device
|
return serialize_dt_fields(device)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices", summary="创建网络设备")
|
@router.post("/devices", summary="创建网络设备")
|
||||||
def create_network_device(
|
def create_network_device(
|
||||||
name: str,
|
name: str,
|
||||||
ip_address: str,
|
ip_address: str,
|
||||||
credential_id: Optional[int] = None,
|
snmp_credential_id: Optional[int] = None, # 前端字段名(与 ORM 列名一致)
|
||||||
port: int = 161,
|
port: int = 161,
|
||||||
device_type: str = "other",
|
device_type: str = "other",
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
@@ -305,7 +293,7 @@ def create_network_device(
|
|||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
port=port,
|
port=port,
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
snmp_credential_id=credential_id,
|
snmp_credential_id=snmp_credential_id,
|
||||||
description=description,
|
description=description,
|
||||||
location=location,
|
location=location,
|
||||||
is_active=True
|
is_active=True
|
||||||
@@ -329,7 +317,7 @@ def create_network_device(
|
|||||||
user_agent=u,
|
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},
|
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
|
return serialize_dt_fields(device)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/devices/{device_id}", summary="更新网络设备")
|
@router.put("/devices/{device_id}", summary="更新网络设备")
|
||||||
@@ -337,7 +325,8 @@ def update_network_device(
|
|||||||
device_id: int,
|
device_id: int,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
ip_address: Optional[str] = None,
|
ip_address: Optional[str] = None,
|
||||||
credential_id: Optional[int] = None,
|
snmp_credential_id: Optional[int] = None,
|
||||||
|
clear_snmp_credential: bool = False, # 新增:专门的标志
|
||||||
port: Optional[int] = None,
|
port: Optional[int] = None,
|
||||||
device_type: Optional[str] = None,
|
device_type: Optional[str] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
@@ -363,8 +352,14 @@ def update_network_device(
|
|||||||
device.name = name
|
device.name = name
|
||||||
if ip_address:
|
if ip_address:
|
||||||
device.ip_address = ip_address
|
device.ip_address = ip_address
|
||||||
if credential_id is not None:
|
# 处理 SNMP 凭据:
|
||||||
device.snmp_credential_id = credential_id
|
# - clear_snmp_credential=True → 清除凭据
|
||||||
|
# - snmp_credential_id > 0 → 设置为该值
|
||||||
|
# - 否则(都不做任何操作
|
||||||
|
if clear_snmp_credential:
|
||||||
|
device.snmp_credential_id = None
|
||||||
|
elif snmp_credential_id is not None and snmp_credential_id > 0:
|
||||||
|
device.snmp_credential_id = snmp_credential_id
|
||||||
if port:
|
if port:
|
||||||
device.port = port
|
device.port = port
|
||||||
if device_type:
|
if device_type:
|
||||||
@@ -400,7 +395,7 @@ def update_network_device(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return device
|
return serialize_dt_fields(device)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/devices/{device_id}", summary="删除网络设备")
|
@router.delete("/devices/{device_id}", summary="删除网络设备")
|
||||||
@@ -442,7 +437,7 @@ def delete_network_device(
|
|||||||
# ========== SNMP 操作 ==========
|
# ========== SNMP 操作 ==========
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/test", summary="测试 SNMP 连接")
|
@router.post("/devices/{device_id}/test", summary="测试 SNMP 连接")
|
||||||
def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
|
async def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
|
||||||
"""测试设备的 SNMP 连接(不写审计,避免刷屏)"""
|
"""测试设备的 SNMP 连接(不写审计,避免刷屏)"""
|
||||||
from app.models.snmp import NetworkDevice
|
from app.models.snmp import NetworkDevice
|
||||||
|
|
||||||
@@ -453,7 +448,7 @@ def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
|
|||||||
if not device.snmp_credential:
|
if not device.snmp_credential:
|
||||||
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
|
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
|
||||||
|
|
||||||
success, info = SNMPService.test_connection(device, device.snmp_credential)
|
success, info = await SNMPService.test_connection(device, device.snmp_credential)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"device_id": device_id,
|
"device_id": device_id,
|
||||||
@@ -464,9 +459,9 @@ def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/poll", summary="立即轮询设备")
|
@router.post("/devices/{device_id}/poll", summary="立即轮询设备")
|
||||||
def poll_device_now(device_id: int, db: Session = Depends(get_db)):
|
async def poll_device_now(device_id: int, db: Session = Depends(get_db)):
|
||||||
"""立即轮询设备,获取 ARP 表、MAC 地址表、接口信息(不写审计,常规操作)"""
|
"""立即轮询设备,获取 ARP 表、MAC 地址表、接口信息(不写审计,常规操作)"""
|
||||||
result = SNMPService.poll_device(db, device_id)
|
result = await SNMPService.poll_device(db, device_id)
|
||||||
|
|
||||||
if 'error' in result:
|
if 'error' in result:
|
||||||
raise HTTPException(status_code=400, detail=result['error'])
|
raise HTTPException(status_code=400, detail=result['error'])
|
||||||
@@ -475,7 +470,7 @@ def poll_device_now(device_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/arp", summary="获取设备 ARP 表")
|
@router.post("/devices/{device_id}/arp", summary="获取设备 ARP 表")
|
||||||
def get_device_arp_table(device_id: int, db: Session = Depends(get_db)):
|
async def get_device_arp_table(device_id: int, db: Session = Depends(get_db)):
|
||||||
"""获取设备的 ARP 表"""
|
"""获取设备的 ARP 表"""
|
||||||
from app.models.snmp import NetworkDevice, ARPEntry
|
from app.models.snmp import NetworkDevice, ARPEntry
|
||||||
|
|
||||||
@@ -486,7 +481,7 @@ def get_device_arp_table(device_id: int, db: Session = Depends(get_db)):
|
|||||||
if not device.snmp_credential:
|
if not device.snmp_credential:
|
||||||
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
|
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
|
||||||
|
|
||||||
entries = SNMPService.get_arp_table(device, device.snmp_credential, db)
|
entries = await SNMPService.get_arp_table(device, device.snmp_credential, db)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"device_id": device_id,
|
"device_id": device_id,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -19,17 +19,17 @@ async def lifespan(app: FastAPI):
|
|||||||
"""应用生命周期管理"""
|
"""应用生命周期管理"""
|
||||||
# 启动时创建数据库表
|
# 启动时创建数据库表
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
# 初始化默认管理员账户 + 默认权限
|
# 初始化默认管理员账户
|
||||||
from app.services.user_service import UserService
|
from app.services.user_service import UserService
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
UserService.init_default_admin(db)
|
UserService.init_default_admin(db)
|
||||||
UserService.init_default_permissions(db)
|
print("✅ IPAM System initialized successfully!")
|
||||||
print("✅ 系统初始化完成,默认管理员账户: admin / admin123")
|
print(" Default admin: admin / admin123")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
# 关闭时清理
|
# 关闭时清理
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.APP_NAME,
|
title=settings.APP_NAME,
|
||||||
version=settings.APP_VERSION,
|
version=settings.APP_VERSION,
|
||||||
description="企业级IP地址管理系统",
|
description="Enterprise IP Address Management System",
|
||||||
lifespan=lifespan
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+85
-28
@@ -1,37 +1,94 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
|
from sqlalchemy import Column, Integer, String, Text, DateTime, Enum
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAction(str, enum.Enum):
|
||||||
|
"""操作类型枚举"""
|
||||||
|
CREATE = "create"
|
||||||
|
READ = "read"
|
||||||
|
UPDATE = "update"
|
||||||
|
DELETE = "delete"
|
||||||
|
LOGIN = "login"
|
||||||
|
LOGOUT = "logout"
|
||||||
|
SCAN = "scan"
|
||||||
|
IMPORT = "import"
|
||||||
|
EXPORT = "export"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
# ===== 兼容层:老 AuditAction 字符串字面量别名,值与新枚举对应 =====
|
||||||
|
# 旧代码(以及当前 AuditService._ACTION_MAP)里硬编码引用这些枚举属性名
|
||||||
|
NETWORK_CREATE = "create"
|
||||||
|
NETWORK_UPDATE = "update"
|
||||||
|
NETWORK_DELETE = "delete"
|
||||||
|
IP_UPDATE = "update"
|
||||||
|
IP_RESERVE = "update"
|
||||||
|
IP_UNRESERVE = "update"
|
||||||
|
SCAN_TRIGGER = "scan"
|
||||||
|
SNMP_CRED_CREATE = "create"
|
||||||
|
SNMP_CRED_UPDATE = "update"
|
||||||
|
SNMP_CRED_DELETE = "delete"
|
||||||
|
SNMP_DEVICE_CREATE = "create"
|
||||||
|
SNMP_DEVICE_UPDATE = "update"
|
||||||
|
SNMP_DEVICE_DELETE = "delete"
|
||||||
|
ALERT_ACK = "update"
|
||||||
|
ALERT_RESOLVE = "update"
|
||||||
|
USER_LOGIN = "login"
|
||||||
|
USER_LOGIN_FAILED = "login"
|
||||||
|
USER_LOGOUT = "logout"
|
||||||
|
USER_CREATE = "create"
|
||||||
|
USER_UPDATE_STATUS = "update"
|
||||||
|
USER_UNLOCK = "update"
|
||||||
|
USER_RESET_PASSWORD = "update"
|
||||||
|
USER_DELETE = "delete"
|
||||||
|
USER_CHANGE_PASSWORD = "update"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditResource(str, enum.Enum):
|
||||||
|
"""资源类型枚举"""
|
||||||
|
USER = "user"
|
||||||
|
NETWORK = "network"
|
||||||
|
IP_ADDRESS = "ip_address"
|
||||||
|
SNMP_CREDENTIAL = "snmp_credential"
|
||||||
|
SNMP_DEVICE = "snmp_device"
|
||||||
|
ALERT = "alert"
|
||||||
|
WHITELIST_MAC = "whitelist_mac"
|
||||||
|
SCAN_TASK = "scan_task"
|
||||||
|
SYSTEM = "system"
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
"""审计日志:记录用户的关键操作"""
|
"""审计日志表"""
|
||||||
__tablename__ = "audit_logs"
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
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)
|
user_id = Column(Integer, index=True, nullable=True)
|
||||||
username = Column(String(50), index=True, comment="冗余存储用户名,便于 user 被删除后仍能查看")
|
username = Column(String(50), index=True)
|
||||||
|
real_name = Column(String(50))
|
||||||
# 操作内容
|
user_ip = Column(String(50))
|
||||||
action = Column(String(50), nullable=False, index=True, comment="操作类型,如:network.create")
|
user_agent = Column(String(500))
|
||||||
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="资源名称/标识,便于阅读")
|
action = Column(Enum(AuditAction), nullable=False, index=True)
|
||||||
|
resource = Column(Enum(AuditResource), nullable=False, index=True)
|
||||||
|
resource_id = Column(String(100), nullable=True) # 操作的资源ID
|
||||||
|
|
||||||
|
# 详细信息
|
||||||
|
description = Column(String(500)) # 操作描述
|
||||||
|
old_value = Column(Text) # 修改前的值 (JSON)
|
||||||
|
new_value = Column(Text) # 修改后的值 (JSON)
|
||||||
|
changed_fields = Column(String(500)) # 变更的字段列表
|
||||||
|
|
||||||
# 请求信息
|
# 请求信息
|
||||||
method = Column(String(10), comment="HTTP 方法")
|
request_method = Column(String(10))
|
||||||
path = Column(String(500), comment="请求路径")
|
request_path = Column(String(200))
|
||||||
ip_address = Column(String(50), comment="客户端 IP")
|
|
||||||
user_agent = Column(String(500), comment="User-Agent")
|
# 结果
|
||||||
|
success = Column(Integer, default=1) # 1=成功,0=失败
|
||||||
# 状态
|
error_message = Column(Text)
|
||||||
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(), index=True)
|
||||||
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())
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
统一时间格式化工具
|
||||||
|
|
||||||
|
背景:MySQL DATETIME 不带时区,backend 用 datetime.utcnow() 写入 UTC naive datetime。
|
||||||
|
原 schema 直接 .isoformat() 输出,前端拿到无时区字符串后当成 Asia/Shanghai 本地时间显示,
|
||||||
|
导致所有时间统一少 8 小时。
|
||||||
|
|
||||||
|
本工具把任意 datetime(naive 或 aware)一律按 UTC 输出,带 'Z' 后缀。
|
||||||
|
前端收到 'Z' 后缀字符串后,按 UTC 解析后再 +8 小时显示为 Asia/Shanghai 时间。
|
||||||
|
|
||||||
|
也兼容 MySQL 返回 naive 时被错当成 local time 的情况:调用方传入数字或字符串时
|
||||||
|
不会出错,自动识别。
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
# 业务时区:服务器期望最终用户看到的时区。
|
||||||
|
# 目前写死 Asia/Shanghai (UTC+8);如以后部署到其他时区,改这里即可。
|
||||||
|
BUSINESS_TZ = timezone(timedelta(hours=8))
|
||||||
|
BUSINESS_TZ_NAME = "Asia/Shanghai"
|
||||||
|
|
||||||
|
|
||||||
|
def to_utc_iso(dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
把 datetime 转成 UTC ISO8601 字符串(带 Z 后缀)。
|
||||||
|
- aware datetime 调 astimezone(UTC)
|
||||||
|
- naive datetime 假定为 UTC(与 datetime.utcnow() 写入策略一致)
|
||||||
|
- None 返回 None
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
# naive 一律视为 UTC(与 datetime.utcnow() 写入策略一致)
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
# 用 '+00:00' 替换成 'Z',更标准的 ISO8601 形式
|
||||||
|
return dt.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||||
|
|
||||||
|
|
||||||
|
def to_business_iso(dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
把 datetime 转成业务时区(Asia/Shanghai)的 ISO8601 字符串(带 +08:00 后缀)。
|
||||||
|
前端拿到后可直接当本地时间显示。
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(BUSINESS_TZ).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def utc_str_to_business_str(s: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
把 'YYYY-MM-DDTHH:MM:SS[.ffffff][Z|+HH:MM]' 字符串按 UTC 解析,
|
||||||
|
转成业务时区 ISO8601 字符串。
|
||||||
|
用于后端已经输出 UTC 时,前端(或后端自己)做时区转换的辅助函数。
|
||||||
|
"""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s.replace('Z', '+00:00'))
|
||||||
|
return to_business_iso(dt)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def now_business_iso() -> str:
|
||||||
|
"""供后端临时插入用:返回当前业务时区时间"""
|
||||||
|
return datetime.now(BUSINESS_TZ).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_dt_fields(model, fields: Optional[list] = None) -> dict:
|
||||||
|
"""
|
||||||
|
把 ORM model 转成 dict,datetime 字段自动用业务时区 ISO 字符串输出。
|
||||||
|
|
||||||
|
用于 audit/snmp/alerts 等内联 dict 序列化的 endpoint,确保返回的
|
||||||
|
时间字段不会因为裸 .isoformat() 输出而少 8 小时。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
return serialize_dt_fields(alert, ['created_at', 'updated_at'])
|
||||||
|
return serialize_dt_fields(alert) # 自动检测所有 datetime 字段
|
||||||
|
"""
|
||||||
|
from sqlalchemy import inspect as sa_inspect
|
||||||
|
result = {}
|
||||||
|
# inspect Class(不是 instance),可以拿 mapper.columns
|
||||||
|
mapper = sa_inspect(type(model)) if not isinstance(model, type) else sa_inspect(model)
|
||||||
|
datetime_fields: list = []
|
||||||
|
if fields is None:
|
||||||
|
for column in mapper.columns:
|
||||||
|
col_type = str(column.type).upper()
|
||||||
|
if 'DATETIME' in col_type or 'TIMESTAMP' in col_type:
|
||||||
|
datetime_fields.append(column.key)
|
||||||
|
else:
|
||||||
|
datetime_fields = fields
|
||||||
|
for column in mapper.columns:
|
||||||
|
value = getattr(model, column.key, None)
|
||||||
|
if column.key in datetime_fields:
|
||||||
|
result[column.key] = to_business_iso(value)
|
||||||
|
else:
|
||||||
|
if hasattr(value, 'value'): # Enum
|
||||||
|
result[column.key] = value.value
|
||||||
|
else:
|
||||||
|
result[column.key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_dt_list(models, fields: Optional[list] = None) -> list:
|
||||||
|
"""批量版本:返回每个 model 的 dict 列表"""
|
||||||
|
return [serialize_dt_fields(m, fields) for m in models] # type: ignore
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator, field_serializer
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from app.models.network import IPStatus, TaskStatus, TaskType
|
from app.models.network import IPStatus, TaskStatus, TaskType
|
||||||
|
from app.schemas._tz_util import to_business_iso, to_utc_iso
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic v2 字段序列化器:把 datetime 转成业务时区 ISO 字符串
|
||||||
|
# 让前端拿到 'YYYY-MM-DDTHH:MM:SS+08:00',可直接当本地时间显示
|
||||||
|
_datetime_business_serializer = field_serializer(
|
||||||
|
'datetime',
|
||||||
|
when_used='always',
|
||||||
|
check_fields=None, # 重要:应用到所有 datetime 字段
|
||||||
|
)(lambda dt: to_business_iso(dt))
|
||||||
|
|
||||||
|
|
||||||
# ========== 网段相关 Schemas ==========
|
# ========== 网段相关 Schemas ==========
|
||||||
@@ -55,6 +65,11 @@ class Network(NetworkBase):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime]
|
updated_at: Optional[datetime]
|
||||||
|
|
||||||
|
# Pydantic v2: 用 model_serializer 在序列化整个 model 时把所有 datetime 转成业务时区
|
||||||
|
@field_serializer('created_at', 'updated_at')
|
||||||
|
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
return to_business_iso(dt)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
@@ -120,6 +135,10 @@ class IPAddress(IPAddressBase):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime]
|
updated_at: Optional[datetime]
|
||||||
|
|
||||||
|
@field_serializer('last_seen', 'first_seen', 'created_at', 'updated_at')
|
||||||
|
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
return to_business_iso(dt)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
@@ -155,6 +174,13 @@ class ScanTask(BaseModel):
|
|||||||
error_message: Optional[str]
|
error_message: Optional[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
@field_serializer(
|
||||||
|
'started_at', 'completed_at', 'created_at',
|
||||||
|
check_fields=None,
|
||||||
|
)
|
||||||
|
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
return to_business_iso(dt)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
@@ -170,4 +196,4 @@ class ScanResult(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
mac_address: Optional[str] = None
|
mac_address: Optional[str] = None
|
||||||
hostname: Optional[str] = None
|
hostname: Optional[str] = None
|
||||||
response_time: Optional[float] = None
|
response_time: Optional[float] = None
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,13 +1,59 @@
|
|||||||
from typing import Optional, Dict, Any, List, Tuple
|
from typing import Optional, List, Any
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import desc
|
from datetime import datetime, timedelta
|
||||||
from datetime import datetime
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog, AuditAction, AuditResource
|
||||||
from app.models.auth import User
|
from app.models.auth import User
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 兼容层:老 API(字符串 action/resource)映射到新枚举 =====
|
||||||
|
|
||||||
|
# 字符串 action -> 枚举 + 资源类型;用于兼容各 endpoint 里硬编码的 "network.create" 等
|
||||||
|
_ACTION_MAP = {
|
||||||
|
# network
|
||||||
|
"network.create": (AuditAction.CREATE, AuditResource.NETWORK),
|
||||||
|
"network.update": (AuditAction.UPDATE, AuditResource.NETWORK),
|
||||||
|
"network.delete": (AuditAction.DELETE, AuditResource.NETWORK),
|
||||||
|
# ip
|
||||||
|
"ip.update": (AuditAction.UPDATE, AuditResource.IP_ADDRESS),
|
||||||
|
"ip.reserve": (AuditAction.UPDATE, AuditResource.IP_ADDRESS),
|
||||||
|
"ip.unreserve": (AuditAction.UPDATE, AuditResource.IP_ADDRESS),
|
||||||
|
# scan
|
||||||
|
"scan.trigger": (AuditAction.SCAN, AuditResource.SCAN_TASK),
|
||||||
|
# snmp
|
||||||
|
"snmp.credential.create": (AuditAction.CREATE, AuditResource.SNMP_CREDENTIAL),
|
||||||
|
"snmp.credential.update": (AuditAction.UPDATE, AuditResource.SNMP_CREDENTIAL),
|
||||||
|
"snmp.credential.delete": (AuditAction.DELETE, AuditResource.SNMP_CREDENTIAL),
|
||||||
|
"snmp.device.create": (AuditAction.CREATE, AuditResource.SNMP_DEVICE),
|
||||||
|
"snmp.device.update": (AuditAction.UPDATE, AuditResource.SNMP_DEVICE),
|
||||||
|
"snmp.device.delete": (AuditAction.DELETE, AuditResource.SNMP_DEVICE),
|
||||||
|
# alert
|
||||||
|
"alert.acknowledge": (AuditAction.UPDATE, AuditResource.ALERT),
|
||||||
|
"alert.resolve": (AuditAction.UPDATE, AuditResource.ALERT),
|
||||||
|
# user
|
||||||
|
"user.login": (AuditAction.LOGIN, AuditResource.USER),
|
||||||
|
"user.login.failed": (AuditAction.LOGIN, AuditResource.USER),
|
||||||
|
"user.logout": (AuditAction.LOGOUT, AuditResource.USER),
|
||||||
|
"user.create": (AuditAction.CREATE, AuditResource.USER),
|
||||||
|
"user.update_status": (AuditAction.UPDATE, AuditResource.USER),
|
||||||
|
"user.unlock": (AuditAction.UPDATE, AuditResource.USER),
|
||||||
|
"user.reset_password": (AuditAction.UPDATE, AuditResource.USER),
|
||||||
|
"user.delete": (AuditAction.DELETE, AuditResource.USER),
|
||||||
|
"user.change_password": (AuditAction.UPDATE, AuditResource.USER),
|
||||||
|
}
|
||||||
|
|
||||||
|
_RESOURCE_MAP = {
|
||||||
|
"network": AuditResource.NETWORK,
|
||||||
|
"ip": AuditResource.IP_ADDRESS,
|
||||||
|
"snmp_credential": AuditResource.SNMP_CREDENTIAL,
|
||||||
|
"snmp_device": AuditResource.SNMP_DEVICE,
|
||||||
|
"user": AuditResource.USER,
|
||||||
|
"alert": AuditResource.ALERT,
|
||||||
|
"scan": AuditResource.SCAN_TASK,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AuditService:
|
class AuditService:
|
||||||
"""审计日志服务"""
|
"""审计日志服务"""
|
||||||
|
|
||||||
@@ -16,9 +62,9 @@ class AuditService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
action: str,
|
action: str,
|
||||||
user: Optional[User] = None,
|
user=None,
|
||||||
resource_type: Optional[str] = None,
|
resource_type: Optional[str] = None,
|
||||||
resource_id: Optional[str] = None,
|
resource_id: Optional[Any] = None,
|
||||||
resource_name: Optional[str] = None,
|
resource_name: Optional[str] = None,
|
||||||
method: Optional[str] = None,
|
method: Optional[str] = None,
|
||||||
path: Optional[str] = None,
|
path: Optional[str] = None,
|
||||||
@@ -26,130 +72,299 @@ class AuditService:
|
|||||||
user_agent: Optional[str] = None,
|
user_agent: Optional[str] = None,
|
||||||
status: str = "success",
|
status: str = "success",
|
||||||
detail: Optional[Any] = None,
|
detail: Optional[Any] = None,
|
||||||
) -> AuditLog:
|
) -> Optional["AuditLog"]:
|
||||||
"""
|
"""
|
||||||
写入一条审计日志。任意字段缺失都安全降级(不抛异常),
|
兼容层:将老版 API(字符串 action/resource)映射到新版 log()。
|
||||||
避免审计日志写入失败影响主业务流程。
|
所有 endpoint 都通过本方法写审计,避免直接调用新签名造成 500。
|
||||||
|
任意环节失败都安全降级(不抛异常),审计写入失败不影响主业务。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
log = AuditLog(
|
# action 字符串 -> (枚举, 资源枚举) ;未知 action 用 OTHER + SYSTEM
|
||||||
user_id=user.id if user else None,
|
enum_action, default_resource = _ACTION_MAP.get(
|
||||||
username=user.username if user else None,
|
action, (AuditAction.OTHER, AuditResource.SYSTEM)
|
||||||
action=action,
|
)
|
||||||
resource_type=resource_type,
|
resource_enum = _RESOURCE_MAP.get(resource_type, default_resource)
|
||||||
resource_id=str(resource_id) if resource_id is not None else None,
|
|
||||||
resource_name=resource_name,
|
# 详情拆分:dict 拆出 old/new;其余当 description
|
||||||
method=method,
|
old_value = None
|
||||||
path=path,
|
new_value = None
|
||||||
ip_address=ip_address,
|
description = None
|
||||||
user_agent=(user_agent or "")[:500],
|
error_message = None
|
||||||
status=status,
|
success = status != "failed"
|
||||||
detail=_serialize_detail(detail),
|
|
||||||
|
if isinstance(detail, dict):
|
||||||
|
old_value = detail.get("before")
|
||||||
|
new_value = detail.get("after")
|
||||||
|
# 把 action 原始字符串 + 资源名放到 description 里,保留细节
|
||||||
|
desc_parts = [action]
|
||||||
|
if resource_name:
|
||||||
|
desc_parts.append(resource_name)
|
||||||
|
if status == "failed":
|
||||||
|
error_message = detail.get("error") or detail.get("message") or json.dumps(detail, ensure_ascii=False, default=str)
|
||||||
|
description = " | ".join(desc_parts)
|
||||||
|
else:
|
||||||
|
description = f"{action} | {resource_name or ''}"
|
||||||
|
if status == "failed" and detail:
|
||||||
|
error_message = str(detail)
|
||||||
|
|
||||||
|
# user 不能 None 传给 log();fallback 到 User(id=None, username='anonymous')
|
||||||
|
# 但 log() 会用 user.id / user.username / user.real_name,如果都是 None 就 OK
|
||||||
|
if user is None:
|
||||||
|
anon = User(id=None, username="anonymous", real_name=None)
|
||||||
|
else:
|
||||||
|
anon = user
|
||||||
|
|
||||||
|
return AuditService.log(
|
||||||
|
db=db,
|
||||||
|
user=anon,
|
||||||
|
action=enum_action,
|
||||||
|
resource=resource_enum,
|
||||||
|
resource_id=str(resource_id) if resource_id is not None else None,
|
||||||
|
description=description[:500] if description else None,
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=new_value,
|
||||||
|
user_ip=ip_address,
|
||||||
|
user_agent=(user_agent or "")[:500],
|
||||||
|
request_method=method,
|
||||||
|
request_path=path,
|
||||||
|
success=success,
|
||||||
|
error_message=error_message,
|
||||||
)
|
)
|
||||||
db.add(log)
|
|
||||||
db.commit()
|
|
||||||
return log
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 写审计日志失败不能影响主业务,仅回滚
|
|
||||||
db.rollback()
|
db.rollback()
|
||||||
# 用 print 而非 logger,避免循环依赖(logger 可能未初始化)
|
print(f"[audit] record() failed action={action}: {e}")
|
||||||
print(f"[audit] failed to write log action={action}: {e}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_logs(
|
def log(db: Session,
|
||||||
db: Session,
|
user: User,
|
||||||
*,
|
action: AuditAction,
|
||||||
user_id: Optional[int] = None,
|
resource: AuditResource,
|
||||||
username: Optional[str] = None,
|
resource_id: Optional[str] = None,
|
||||||
action: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
resource_type: Optional[str] = None,
|
old_value: Optional[dict] = None,
|
||||||
status: Optional[str] = None,
|
new_value: Optional[dict] = None,
|
||||||
start_time: Optional[datetime] = None,
|
changed_fields: Optional[List[str]] = None,
|
||||||
end_time: Optional[datetime] = None,
|
user_ip: Optional[str] = None,
|
||||||
skip: int = 0,
|
user_agent: Optional[str] = None,
|
||||||
limit: int = 50,
|
request_method: Optional[str] = None,
|
||||||
) -> Tuple[int, List[AuditLog]]:
|
request_path: Optional[str] = None,
|
||||||
|
success: bool = True,
|
||||||
|
error_message: Optional[str] = None) -> AuditLog:
|
||||||
|
"""
|
||||||
|
记录审计日志
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
user: 操作用户
|
||||||
|
action: 操作类型
|
||||||
|
resource: 资源类型
|
||||||
|
resource_id: 资源ID
|
||||||
|
description: 操作描述
|
||||||
|
old_value: 旧值
|
||||||
|
new_value: 新值
|
||||||
|
changed_fields: 变更的字段列表
|
||||||
|
user_ip: 用户IP
|
||||||
|
user_agent: 用户代理
|
||||||
|
request_method: 请求方法
|
||||||
|
request_path: 请求路径
|
||||||
|
success: 操作是否成功
|
||||||
|
error_message: 错误信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuditLog 实例
|
||||||
|
"""
|
||||||
|
audit_log = AuditLog(
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
username=user.username if user else None,
|
||||||
|
real_name=user.real_name if user else None,
|
||||||
|
action=action,
|
||||||
|
resource=resource,
|
||||||
|
resource_id=str(resource_id) if resource_id else None,
|
||||||
|
description=description,
|
||||||
|
old_value=json.dumps(old_value, ensure_ascii=False, default=str) if old_value else None,
|
||||||
|
new_value=json.dumps(new_value, ensure_ascii=False, default=str) if new_value else None,
|
||||||
|
changed_fields=','.join(changed_fields) if changed_fields else None,
|
||||||
|
user_ip=user_ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
request_method=request_method,
|
||||||
|
request_path=request_path,
|
||||||
|
success=1 if success else 0,
|
||||||
|
error_message=error_message
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(audit_log)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(audit_log)
|
||||||
|
return audit_log
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_logs(db: Session,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
action: Optional[AuditAction] = None,
|
||||||
|
resource: Optional[AuditResource] = None,
|
||||||
|
success: Optional[bool] = None,
|
||||||
|
start_time: Optional[datetime] = None,
|
||||||
|
end_time: Optional[datetime] = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100) -> tuple[int, List[AuditLog]]:
|
||||||
|
"""
|
||||||
|
查询审计日志
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
user_id: 按用户ID过滤
|
||||||
|
action: 按操作类型过滤
|
||||||
|
resource: 按资源类型过滤
|
||||||
|
success: 按操作结果过滤
|
||||||
|
start_time: 开始时间
|
||||||
|
end_time: 结束时间
|
||||||
|
skip: 跳过记录数
|
||||||
|
limit: 返回记录数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(总数, 记录列表)
|
||||||
|
"""
|
||||||
query = db.query(AuditLog)
|
query = db.query(AuditLog)
|
||||||
|
|
||||||
if user_id is not None:
|
if user_id:
|
||||||
query = query.filter(AuditLog.user_id == user_id)
|
query = query.filter(AuditLog.user_id == user_id)
|
||||||
if username:
|
|
||||||
query = query.filter(AuditLog.username.like(f"%{username}%"))
|
|
||||||
if action:
|
if action:
|
||||||
query = query.filter(AuditLog.action.like(f"%{action}%"))
|
query = query.filter(AuditLog.action == action)
|
||||||
if resource_type:
|
if resource:
|
||||||
query = query.filter(AuditLog.resource_type == resource_type)
|
query = query.filter(AuditLog.resource == resource)
|
||||||
if status:
|
if success is not None:
|
||||||
query = query.filter(AuditLog.status == status)
|
query = query.filter(AuditLog.success == (1 if success else 0))
|
||||||
if start_time:
|
if start_time:
|
||||||
query = query.filter(AuditLog.created_at >= start_time)
|
query = query.filter(AuditLog.created_at >= start_time)
|
||||||
if end_time:
|
if end_time:
|
||||||
query = query.filter(AuditLog.created_at <= end_time)
|
query = query.filter(AuditLog.created_at <= end_time)
|
||||||
|
|
||||||
total = query.count()
|
total = query.count()
|
||||||
items = query.order_by(desc(AuditLog.created_at)).offset(skip).limit(limit).all()
|
items = query.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
return total, items
|
return total, items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_stats(db: Session) -> Dict[str, Any]:
|
def get_user_actions(db: Session, user_id: int, limit: int = 50) -> List[AuditLog]:
|
||||||
"""首页/汇总用:最近 24h 操作数 + 按 action 分组"""
|
"""获取指定用户的操作日志"""
|
||||||
from datetime import timedelta
|
return db.query(AuditLog)\
|
||||||
cutoff = datetime.utcnow() - timedelta(hours=24)
|
.filter(AuditLog.user_id == user_id)\
|
||||||
recent = db.query(AuditLog).filter(AuditLog.created_at >= cutoff).count()
|
.order_by(AuditLog.created_at.desc())\
|
||||||
by_action = db.query(AuditLog.action, db.query(AuditLog).filter(AuditLog.created_at >= cutoff).subquery()) # 占位避免循环
|
.limit(limit)\
|
||||||
# 简化:用 group by
|
.all()
|
||||||
from sqlalchemy import func
|
|
||||||
rows = db.query(AuditLog.action, func.count(AuditLog.id)).filter(
|
@staticmethod
|
||||||
AuditLog.created_at >= cutoff
|
def get_resource_logs(db: Session, resource: AuditResource, resource_id: str, limit: int = 50) -> List[AuditLog]:
|
||||||
).group_by(AuditLog.action).all()
|
"""获取指定资源的操作历史"""
|
||||||
|
return db.query(AuditLog)\
|
||||||
|
.filter(AuditLog.resource == resource, AuditLog.resource_id == str(resource_id))\
|
||||||
|
.order_by(AuditLog.created_at.desc())\
|
||||||
|
.limit(limit)\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_statistics(db: Session, days: int = 30) -> dict:
|
||||||
|
"""
|
||||||
|
获取审计统计信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
days: 统计天数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统计信息字典
|
||||||
|
"""
|
||||||
|
start_date = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
|
# 总操作数
|
||||||
|
total_ops = db.query(AuditLog).filter(AuditLog.created_at >= start_date).count()
|
||||||
|
|
||||||
|
# 成功/失败统计
|
||||||
|
success_ops = db.query(AuditLog).filter(
|
||||||
|
AuditLog.created_at >= start_date,
|
||||||
|
AuditLog.success == 1
|
||||||
|
).count()
|
||||||
|
failed_ops = total_ops - success_ops
|
||||||
|
|
||||||
|
# 按操作类型统计
|
||||||
|
action_stats = {}
|
||||||
|
for action in AuditAction:
|
||||||
|
count = db.query(AuditLog).filter(
|
||||||
|
AuditLog.created_at >= start_date,
|
||||||
|
AuditLog.action == action
|
||||||
|
).count()
|
||||||
|
if count > 0:
|
||||||
|
action_stats[action.value] = count
|
||||||
|
|
||||||
|
# 按资源类型统计
|
||||||
|
resource_stats = {}
|
||||||
|
for resource in AuditResource:
|
||||||
|
count = db.query(AuditLog).filter(
|
||||||
|
AuditLog.created_at >= start_date,
|
||||||
|
AuditLog.resource == resource
|
||||||
|
).count()
|
||||||
|
if count > 0:
|
||||||
|
resource_stats[resource.value] = count
|
||||||
|
|
||||||
|
# 活跃用户数
|
||||||
|
active_users = db.query(AuditLog.user_id).filter(
|
||||||
|
AuditLog.created_at >= start_date
|
||||||
|
).distinct().count()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"recent_24h": recent,
|
"period_days": days,
|
||||||
"by_action_24h": {action: count for action, count in rows},
|
"total_operations": total_ops,
|
||||||
|
"successful_operations": success_ops,
|
||||||
|
"failed_operations": failed_ops,
|
||||||
|
"success_rate": round(success_ops / total_ops * 100, 2) if total_ops > 0 else 0,
|
||||||
|
"by_action": action_stats,
|
||||||
|
"by_resource": resource_stats,
|
||||||
|
"active_users_count": active_users
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clean_old_logs(db: Session, days: int = 90) -> int:
|
||||||
|
"""
|
||||||
|
清理指定天数之前的日志
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
days: 保留天数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除的记录数
|
||||||
|
"""
|
||||||
|
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||||
|
deleted = db.query(AuditLog).filter(AuditLog.created_at < cutoff_date).delete()
|
||||||
|
db.commit()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
def _serialize_detail(detail: Any) -> Optional[str]:
|
class AuditLogger:
|
||||||
"""把 dict/list 等结构化数据序列化成字符串,便于存储和展示"""
|
"""审计日志装饰器/上下文管理器"""
|
||||||
if detail is None:
|
|
||||||
return None
|
def __init__(self, db: Session, user: User, resource: AuditResource, resource_id: Optional[str] = None):
|
||||||
if isinstance(detail, str):
|
self.db = db
|
||||||
return detail[:4000]
|
self.user = user
|
||||||
try:
|
self.resource = resource
|
||||||
return json.dumps(detail, ensure_ascii=False, default=str)[:4000]
|
self.resource_id = resource_id
|
||||||
except (TypeError, ValueError):
|
self.old_value = None
|
||||||
return str(detail)[:4000]
|
|
||||||
|
def set_old_value(self, value: dict):
|
||||||
|
"""设置旧值(用于更新操作)"""
|
||||||
# 常用 action 字符串集中管理(避免散落各处的魔法字符串)
|
self.old_value = value
|
||||||
class AuditAction:
|
|
||||||
# 网络
|
def log(self, action: AuditAction, description: str, new_value: Optional[dict] = None, changed_fields: Optional[List[str]] = None):
|
||||||
NETWORK_CREATE = "network.create"
|
"""记录日志"""
|
||||||
NETWORK_UPDATE = "network.update"
|
AuditService.log(
|
||||||
NETWORK_DELETE = "network.delete"
|
db=self.db,
|
||||||
# IP
|
user=self.user,
|
||||||
IP_UPDATE = "ip.update"
|
action=action,
|
||||||
IP_RESERVE = "ip.reserve"
|
resource=self.resource,
|
||||||
IP_UNRESERVE = "ip.unreserve"
|
resource_id=self.resource_id,
|
||||||
# 扫描
|
description=description,
|
||||||
SCAN_TRIGGER = "scan.trigger"
|
old_value=self.old_value,
|
||||||
# SNMP
|
new_value=new_value,
|
||||||
SNMP_CRED_CREATE = "snmp.credential.create"
|
changed_fields=changed_fields
|
||||||
SNMP_CRED_UPDATE = "snmp.credential.update"
|
)
|
||||||
SNMP_CRED_DELETE = "snmp.credential.delete"
|
|
||||||
SNMP_DEVICE_CREATE = "snmp.device.create"
|
|
||||||
SNMP_DEVICE_UPDATE = "snmp.device.update"
|
|
||||||
SNMP_DEVICE_DELETE = "snmp.device.delete"
|
|
||||||
# 告警
|
|
||||||
ALERT_ACK = "alert.acknowledge"
|
|
||||||
ALERT_RESOLVE = "alert.resolve"
|
|
||||||
# 用户管理
|
|
||||||
USER_LOGIN = "user.login"
|
|
||||||
USER_LOGIN_FAILED = "user.login.failed"
|
|
||||||
USER_LOGOUT = "user.logout"
|
|
||||||
USER_CREATE = "user.create"
|
|
||||||
USER_UPDATE_STATUS = "user.update_status"
|
|
||||||
USER_UNLOCK = "user.unlock"
|
|
||||||
USER_RESET_PASSWORD = "user.reset_password"
|
|
||||||
USER_DELETE = "user.delete"
|
|
||||||
USER_CHANGE_PASSWORD = "user.change_password"
|
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
import smtplib
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettings(BaseSettings):
|
||||||
|
"""邮件配置"""
|
||||||
|
SMTP_HOST: str = "localhost"
|
||||||
|
SMTP_PORT: int = 25
|
||||||
|
SMTP_USER: Optional[str] = None
|
||||||
|
SMTP_PASSWORD: Optional[str] = None
|
||||||
|
SMTP_USE_TLS: bool = False
|
||||||
|
SMTP_USE_SSL: bool = False
|
||||||
|
SMTP_FROM: str = "ipam@localhost"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_prefix = "EMAIL_"
|
||||||
|
|
||||||
|
|
||||||
|
class EmailService:
|
||||||
|
"""邮件服务"""
|
||||||
|
|
||||||
|
def __init__(self, settings: EmailSettings):
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
def send_email(self, to: str, subject: str, html_content: str) -> bool:
|
||||||
|
"""
|
||||||
|
发送HTML邮件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to: 收件人邮箱
|
||||||
|
subject: 邮件主题
|
||||||
|
html_content: HTML内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否发送成功
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['From'] = self.settings.SMTP_FROM
|
||||||
|
msg['To'] = to
|
||||||
|
msg['Subject'] = subject
|
||||||
|
|
||||||
|
html_part = MIMEText(html_content, 'html', 'utf-8')
|
||||||
|
msg.attach(html_part)
|
||||||
|
|
||||||
|
# 连接SMTP服务器
|
||||||
|
if self.settings.SMTP_USE_SSL:
|
||||||
|
server = smtplib.SMTP_SSL(
|
||||||
|
host=self.settings.SMTP_HOST,
|
||||||
|
port=self.settings.SMTP_PORT,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(
|
||||||
|
host=self.settings.SMTP_HOST,
|
||||||
|
port=self.settings.SMTP_PORT,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果需要TLS加密
|
||||||
|
if self.settings.SMTP_USE_TLS and not self.settings.SMTP_USE_SSL:
|
||||||
|
server.starttls()
|
||||||
|
|
||||||
|
# 如果需要认证
|
||||||
|
if self.settings.SMTP_USER and self.settings.SMTP_PASSWORD:
|
||||||
|
server.login(self.settings.SMTP_USER, self.settings.SMTP_PASSWORD)
|
||||||
|
|
||||||
|
# 发送邮件
|
||||||
|
server.send_message(msg)
|
||||||
|
server.quit()
|
||||||
|
|
||||||
|
logger.info(f"邮件发送成功: {to} - {subject}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"邮件发送失败: {str(e)}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_alert_notification(self, to: str, alert_data: dict) -> bool:
|
||||||
|
"""发送告警通知邮件"""
|
||||||
|
severity_colors = {
|
||||||
|
'critical': '#dc2626',
|
||||||
|
'error': '#ef4444',
|
||||||
|
'warning': '#f59e0b',
|
||||||
|
'info': '#3b82f6'
|
||||||
|
}
|
||||||
|
|
||||||
|
severity_texts = {
|
||||||
|
'critical': '严重',
|
||||||
|
'error': '错误',
|
||||||
|
'warning': '警告',
|
||||||
|
'info': '信息'
|
||||||
|
}
|
||||||
|
|
||||||
|
alert_type_texts = {
|
||||||
|
'ip_conflict': 'IP 地址冲突',
|
||||||
|
'unauthorized_access': '未授权设备接入',
|
||||||
|
'subnet_full': '网段容量不足',
|
||||||
|
'device_offline': '设备离线',
|
||||||
|
'new_device_detected': '新设备发现',
|
||||||
|
'mac_changed': 'MAC 地址变更'
|
||||||
|
}
|
||||||
|
|
||||||
|
severity = alert_data.get('severity', 'warning')
|
||||||
|
color = severity_colors.get(severity, '#6b7280')
|
||||||
|
severity_text = severity_texts.get(severity, severity)
|
||||||
|
alert_type_text = alert_type_texts.get(alert_data.get('alert_type'), alert_data.get('alert_type'))
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||||
|
.header {{ background-color: {color}; color: white; padding: 20px; border-radius: 5px; }}
|
||||||
|
.content {{ padding: 20px; background-color: #f9fafb; border-radius: 5px; margin-top: 10px; }}
|
||||||
|
.alert-item {{ margin-bottom: 15px; }}
|
||||||
|
.label {{ font-weight: bold; color: #6b7280; display: inline-block; width: 100px; }}
|
||||||
|
.footer {{ margin-top: 20px; padding-top: 20px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h2>⚠️ IPAM 告警通知</h2>
|
||||||
|
<p>告警级别: <strong>{severity_text}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="alert-item">
|
||||||
|
<span class="label">告警类型:</span> {alert_type_text}
|
||||||
|
</div>
|
||||||
|
<div class="alert-item">
|
||||||
|
<span class="label">告警标题:</span> {alert_data.get('title', '-')}
|
||||||
|
</div>
|
||||||
|
<div class="alert-item">
|
||||||
|
<span class="label">详细信息:</span> {alert_data.get('message', '-')}
|
||||||
|
</div>
|
||||||
|
<div class="alert-item">
|
||||||
|
<span class="label">相关IP:</span> {alert_data.get('ip_address_str', '-')}
|
||||||
|
</div>
|
||||||
|
<div class="alert-item">
|
||||||
|
<span class="label">相关MAC:</span> {alert_data.get('mac_address', '-')}
|
||||||
|
</div>
|
||||||
|
<div class="alert-item">
|
||||||
|
<span class="label">告警时间:</span> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>此邮件由 IPAM 地址管理系统自动发送,请勿直接回复。</p>
|
||||||
|
<p>如需处理该告警,请登录 IPAM 管理控制台: <a href="#">点击访问</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
subject = f"【{severity_text}】IPAM 告警 - {alert_data.get('title', '新告警')}"
|
||||||
|
|
||||||
|
return self.send_email(to, subject, html_content)
|
||||||
|
|
||||||
|
def send_scan_report(self, to: str, report_data: dict) -> bool:
|
||||||
|
"""发送扫描报告邮件"""
|
||||||
|
html_content = f"""
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||||
|
.header {{ background-color: #3b82f6; color: white; padding: 20px; border-radius: 5px; }}
|
||||||
|
.content {{ padding: 20px; background-color: #f9fafb; border-radius: 5px; margin-top: 10px; }}
|
||||||
|
.stat-item {{ display: inline-block; width: 22%; text-align: center; padding: 15px; background: white; border-radius: 5px; margin: 1%; }}
|
||||||
|
.stat-number {{ font-size: 28px; font-weight: bold; color: #3b82f6; }}
|
||||||
|
.stat-label {{ color: #6b7280; font-size: 14px; }}
|
||||||
|
.success {{ color: #10b981; }}
|
||||||
|
.warning {{ color: #f59e0b; }}
|
||||||
|
.footer {{ margin-top: 20px; padding-top: 20px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h2>📊 IPAM 网段扫描报告</h2>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>扫描结果概览</h3>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">{report_data.get('total_ips', 0)}</div>
|
||||||
|
<div class="stat-label">扫描IP总数</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number success">{report_data.get('online_ips', 0)}</div>
|
||||||
|
<div class="stat-label">在线设备</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">{report_data.get('with_mac', 0)}</div>
|
||||||
|
<div class="stat-label">发现MAC地址</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number warning">{report_data.get('new_devices', 0)}</div>
|
||||||
|
<div class="stat-label">新发现设备</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="clear: both; margin-top: 20px;">
|
||||||
|
<p><strong>扫描网段:</strong> {report_data.get('network_cidr', '-')}</p>
|
||||||
|
<p><strong>扫描时间:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>此邮件由 IPAM 地址管理系统自动发送,请勿直接回复。</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
subject = f"IPAM 扫描报告 - {report_data.get('network_cidr', '')}"
|
||||||
|
|
||||||
|
return self.send_email(to, subject, html_content)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局邮件服务实例
|
||||||
|
_email_service = None
|
||||||
|
|
||||||
|
def get_email_service() -> EmailService:
|
||||||
|
"""获取邮件服务实例"""
|
||||||
|
global _email_service
|
||||||
|
if _email_service is None:
|
||||||
|
settings = EmailSettings()
|
||||||
|
_email_service = EmailService(settings)
|
||||||
|
return _email_service
|
||||||
|
|
||||||
|
|
||||||
|
def init_email_service(settings: EmailSettings):
|
||||||
|
"""初始化邮件服务"""
|
||||||
|
global _email_service
|
||||||
|
_email_service = EmailService(settings)
|
||||||
@@ -6,96 +6,147 @@ import subprocess
|
|||||||
import socket
|
import socket
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
from mac_vendor_lookup import MacLookup, VendorNotFoundError
|
||||||
|
MAC_LOOKUP_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
MAC_LOOKUP_AVAILABLE = False
|
||||||
|
|
||||||
from app.models.network import ScanTask, Network, IPAddress
|
from app.models.network import ScanTask, Network, IPAddress
|
||||||
from app.models.network import TaskStatus, TaskType, IPStatus
|
from app.models.network import TaskStatus, TaskType, IPStatus
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = __import__('logging').getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# OUI 数据库文件路径(相对于项目根)
|
||||||
|
_OUI_DB_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'oui.txt')
|
||||||
|
|
||||||
|
|
||||||
|
def _load_oui_database() -> Optional[Dict[str, str]]:
|
||||||
|
"""从本地文件加载 OUI 数据库,返回 {prefix: vendor} 字典"""
|
||||||
|
if not os.path.exists(_OUI_DB_FILE):
|
||||||
|
logger.warning(f"OUI 数据库文件不存在: {_OUI_DB_FILE}")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
db = {}
|
||||||
|
with open(_OUI_DB_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if ':' in line:
|
||||||
|
prefix, vendor = line.split(':', 1)
|
||||||
|
db[prefix] = vendor
|
||||||
|
logger.info(f"已加载 OUI 数据库: {len(db)} 条记录, 文件: {_OUI_DB_FILE}")
|
||||||
|
return db
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"加载 OUI 数据库失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# 全局 OUI 数据库(延迟加载)
|
||||||
|
_OUI_DB = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_oui_db() -> Optional[Dict[str, str]]:
|
||||||
|
global _OUI_DB
|
||||||
|
if _OUI_DB is None:
|
||||||
|
_OUI_DB = _load_oui_database()
|
||||||
|
return _OUI_DB
|
||||||
|
|
||||||
|
|
||||||
class EnhancedScanService:
|
class EnhancedScanService:
|
||||||
"""增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别"""
|
"""增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别、NetBIOS主机名发现"""
|
||||||
|
|
||||||
# MAC OUI 厂商映射(常用厂商)
|
# 初始化 MAC 厂商查询(单例,共享 OUI 数据库)
|
||||||
OUI_MAP = {
|
_mac_lookup = None
|
||||||
'00:1C:14': 'VMware',
|
|
||||||
'00:0C:29': 'VMware',
|
@classmethod
|
||||||
'00:50:56': 'VMware',
|
def _get_mac_lookup(cls):
|
||||||
'00:15:5D': 'Microsoft',
|
"""获取或初始化 MacLookup 实例"""
|
||||||
'08:00:27': 'VirtualBox',
|
if cls._mac_lookup is None and MAC_LOOKUP_AVAILABLE:
|
||||||
'52:54:00': 'QEMU/KVM',
|
cls._mac_lookup = MacLookup()
|
||||||
'00:1A:2A': 'Dell',
|
return cls._mac_lookup
|
||||||
'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
|
@staticmethod
|
||||||
def get_mac_vendor(mac_address: str) -> Optional[str]:
|
def get_mac_vendor(mac_address: str) -> Optional[str]:
|
||||||
"""根据MAC地址OUI识别厂商"""
|
"""根据 MAC 地址 OUI 识别厂商"""
|
||||||
if not mac_address:
|
if not mac_address:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 标准化MAC格式为 AA:BB:CC:DD:EE:FF
|
# 标准化 MAC 格式为 AA:BB:CC:DD:EE:FF
|
||||||
mac = mac_address.upper().replace('-', ':')
|
mac = mac_address.upper().replace('-', ':')
|
||||||
if len(mac) < 8:
|
if len(mac) < 8:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 提取前3字节 OUI
|
# 提取 OUI(前 6 位十六进制 = AA:BB:CC)
|
||||||
oui = mac[:8]
|
oui = mac[:8] # "AA:BB:CC"
|
||||||
|
|
||||||
# 精确匹配
|
# 1️⃣ 优先使用本地 OUI 数据库(免网络、高性能)
|
||||||
if oui in EnhancedScanService.OUI_MAP:
|
db = _get_oui_db()
|
||||||
return EnhancedScanService.OUI_MAP[oui]
|
if db:
|
||||||
|
# 数据库 key 是 AABBCC 格式(无冒号)
|
||||||
# 尝试模糊匹配(前2字节)
|
oui_key = oui.replace(':', '')
|
||||||
oui_short = mac[:5]
|
vendor = db.get(oui_key)
|
||||||
for key, vendor in EnhancedScanService.OUI_MAP.items():
|
if vendor:
|
||||||
if key.startswith(oui_short):
|
|
||||||
return vendor
|
return vendor
|
||||||
|
|
||||||
|
# 2️⃣ 回退:mac_vendor_lookup 在线库
|
||||||
|
if MAC_LOOKUP_AVAILABLE:
|
||||||
|
lookup = EnhancedScanService._get_mac_lookup()
|
||||||
|
if lookup is not None:
|
||||||
|
try:
|
||||||
|
vendor = lookup.lookup(oui)
|
||||||
|
if vendor:
|
||||||
|
return vendor
|
||||||
|
except (VendorNotFoundError, AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3️⃣ 回退:硬编码常用厂商映射
|
||||||
|
return EnhancedScanService._legacy_oui_lookup(mac)
|
||||||
|
|
||||||
|
# 硬编码回退映射(mac_vendor_lookup 不可用时使用)
|
||||||
|
_LEGACY_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 _legacy_oui_lookup(mac: str) -> Optional[str]:
|
||||||
|
"""硬编码 OUI 回退查询"""
|
||||||
|
oui = mac[:8]
|
||||||
|
if oui in EnhancedScanService._LEGACY_OUI_MAP:
|
||||||
|
return EnhancedScanService._LEGACY_OUI_MAP[oui]
|
||||||
|
# 模糊匹配(前 2 字节)
|
||||||
|
oui_short = mac[:5]
|
||||||
|
for key, vendor in EnhancedScanService._LEGACY_OUI_MAP.items():
|
||||||
|
if key.startswith(oui_short):
|
||||||
|
return vendor
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reverse_dns_lookup(ip_address: str, timeout: int = 2) -> Optional[str]:
|
def reverse_dns_lookup(ip_address: str, timeout: int = 2) -> Optional[str]:
|
||||||
"""反向DNS解析获取主机名"""
|
"""反向 DNS 解析获取主机名"""
|
||||||
try:
|
try:
|
||||||
socket.setdefaulttimeout(timeout)
|
socket.setdefaulttimeout(timeout)
|
||||||
hostname, _, _ = socket.gethostbyaddr(ip_address)
|
hostname, _, _ = socket.gethostbyaddr(ip_address)
|
||||||
@@ -103,6 +154,64 @@ class EnhancedScanService:
|
|||||||
except (socket.herror, socket.timeout, socket.gaierror):
|
except (socket.herror, socket.timeout, socket.gaierror):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def netbios_lookup(ip_address: str, timeout: int = 3) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
通过 NetBIOS 广播协议获取主机名(Windows / SMB 设备)
|
||||||
|
使用 nmblookup -A <ip> 查询
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['nmblookup', '-A', ip_address],
|
||||||
|
capture_output=True, text=True, timeout=timeout
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
# nmblookup 输出格式示例:
|
||||||
|
# querying host 192.168.1.100 on 192.168.1.255
|
||||||
|
#
|
||||||
|
# WORKGROUP<00> - <ACTIVE> B
|
||||||
|
# WORKGROUP<00> - <GROUP> B
|
||||||
|
# WORKGROUP<1D> - <GROUP> B
|
||||||
|
# SERVER01<20> - <ACTIVE> B
|
||||||
|
# 主机名通常是 <20> 服务的第一列
|
||||||
|
for line in result.stdout.strip().split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('querying'):
|
||||||
|
continue
|
||||||
|
# 匹配 "HOSTNAME<20> - <ACTIVE> B" 格式
|
||||||
|
match = re.match(
|
||||||
|
r'^(\S+?)<20>\s+-\s+<ACTIVE>',
|
||||||
|
line,
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
return match.group(1).upper()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def snmp_sysname_lookup(device_id: int, db: Session) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
通过 SNMP sysName 获取主机名(网络设备 / 服务器)
|
||||||
|
仅在扫描时已配置 SNMP 凭据的设备上调用
|
||||||
|
"""
|
||||||
|
from app.models.snmp import NetworkDevice
|
||||||
|
from app.services.snmp_service import SNMPService
|
||||||
|
try:
|
||||||
|
device = db.query(NetworkDevice).filter(
|
||||||
|
NetworkDevice.id == device_id
|
||||||
|
).first()
|
||||||
|
if device and device.snmp_credential:
|
||||||
|
success, info = SNMPService.test_connection(
|
||||||
|
device, device.snmp_credential
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
return info.get('sys_name')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def arp_scan_single(ip_address: str, timeout: int = 2) -> Optional[str]:
|
def arp_scan_single(ip_address: str, timeout: int = 2) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
@@ -116,7 +225,6 @@ class EnhancedScanService:
|
|||||||
capture_output=True, text=True, timeout=timeout + 2
|
capture_output=True, text=True, timeout=timeout + 2
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# arp-scan 输出格式: IP\tMAC\tVendor
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in result.stdout.strip().split('\n'):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
@@ -155,7 +263,6 @@ class EnhancedScanService:
|
|||||||
timeout=2
|
timeout=2
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# 匹配 MAC 地址格式
|
|
||||||
mac_pattern = r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})'
|
mac_pattern = r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})'
|
||||||
match = re.search(mac_pattern, result.stdout)
|
match = re.search(mac_pattern, result.stdout)
|
||||||
if match:
|
if match:
|
||||||
@@ -170,9 +277,20 @@ class EnhancedScanService:
|
|||||||
enable_ping: bool = True,
|
enable_ping: bool = True,
|
||||||
enable_arp: bool = True,
|
enable_arp: bool = True,
|
||||||
enable_dns: bool = True,
|
enable_dns: bool = True,
|
||||||
|
enable_netbios: bool = True,
|
||||||
timeout: int = 2) -> Dict[str, Any]:
|
timeout: int = 2) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
综合扫描单个IP
|
综合扫描单个IP
|
||||||
|
|
||||||
|
厂商识别:
|
||||||
|
- 主要: mac_vendor_lookup 库(IEEE OUI 官方数据库,~17万条)
|
||||||
|
- 回退: 硬编码 OUI_MAP
|
||||||
|
|
||||||
|
主机名发现(多源,按优先级):
|
||||||
|
1. 反向 DNS 解析(PTR)
|
||||||
|
2. NetBIOS 广播(Windows / SMB 设备)
|
||||||
|
3. SNMP sysName(网络设备 / 服务器,需配置 SNMP)
|
||||||
|
|
||||||
返回: {
|
返回: {
|
||||||
'ip_address': str,
|
'ip_address': str,
|
||||||
'status': 'online'|'offline',
|
'status': 'online'|'offline',
|
||||||
@@ -203,14 +321,21 @@ class EnhancedScanService:
|
|||||||
mac = EnhancedScanService.arp_scan_single(ip_address, timeout)
|
mac = EnhancedScanService.arp_scan_single(ip_address, timeout)
|
||||||
if mac:
|
if mac:
|
||||||
result['mac_address'] = mac
|
result['mac_address'] = mac
|
||||||
|
# 厂商识别:优先 mac_vendor_lookup 库,失败后回退硬编码映射
|
||||||
result['vendor'] = EnhancedScanService.get_mac_vendor(mac)
|
result['vendor'] = EnhancedScanService.get_mac_vendor(mac)
|
||||||
# 如果获取到MAC,说明设备实际上在线
|
# 如果获取到MAC,说明设备实际上在线
|
||||||
result['status'] = 'online'
|
result['status'] = 'online'
|
||||||
result['success'] = True
|
result['success'] = True
|
||||||
|
|
||||||
# 3. 反向 DNS 解析
|
# 3. 主机名发现(多源,有任一在线即执行)
|
||||||
if enable_dns and result.get('success'):
|
if result.get('success'):
|
||||||
hostname = EnhancedScanService.reverse_dns_lookup(ip_address, timeout)
|
hostname = None
|
||||||
|
# 3a. 反向 DNS
|
||||||
|
if enable_dns:
|
||||||
|
hostname = EnhancedScanService.reverse_dns_lookup(ip_address, timeout)
|
||||||
|
# 3b. NetBIOS(Windows / SMB)
|
||||||
|
if not hostname and enable_netbios:
|
||||||
|
hostname = EnhancedScanService.netbios_lookup(ip_address, timeout=3)
|
||||||
if hostname:
|
if hostname:
|
||||||
result['hostname'] = hostname
|
result['hostname'] = hostname
|
||||||
|
|
||||||
@@ -249,6 +374,7 @@ class EnhancedScanService:
|
|||||||
enable_ping: bool = True,
|
enable_ping: bool = True,
|
||||||
enable_arp: bool = True,
|
enable_arp: bool = True,
|
||||||
enable_dns: bool = True,
|
enable_dns: bool = True,
|
||||||
|
enable_netbios: bool = True,
|
||||||
timeout: int = 2,
|
timeout: int = 2,
|
||||||
max_concurrent: int = 50) -> List[Dict[str, Any]]:
|
max_concurrent: int = 50) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -260,7 +386,6 @@ class EnhancedScanService:
|
|||||||
if not ips:
|
if not ips:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 并发执行;并发数随网段大小自适应
|
|
||||||
worker_count = min(max_concurrent, len(ips))
|
worker_count = min(max_concurrent, len(ips))
|
||||||
results: List[Dict[str, Any]] = []
|
results: List[Dict[str, Any]] = []
|
||||||
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
||||||
@@ -271,6 +396,7 @@ class EnhancedScanService:
|
|||||||
enable_ping=enable_ping,
|
enable_ping=enable_ping,
|
||||||
enable_arp=enable_arp,
|
enable_arp=enable_arp,
|
||||||
enable_dns=enable_dns,
|
enable_dns=enable_dns,
|
||||||
|
enable_netbios=enable_netbios,
|
||||||
timeout=timeout
|
timeout=timeout
|
||||||
): ip
|
): ip
|
||||||
for ip in ips
|
for ip in ips
|
||||||
@@ -303,12 +429,10 @@ class EnhancedScanService:
|
|||||||
|
|
||||||
# 如果IP不存在,尝试自动创建
|
# 如果IP不存在,尝试自动创建
|
||||||
if not db_ip:
|
if not db_ip:
|
||||||
# 从ip_address推断network_id
|
|
||||||
network = db.query(Network).filter(
|
network = db.query(Network).filter(
|
||||||
Network.cidr.op('@>')((ip_address + '/32').encode('utf-8'))
|
Network.cidr.op('@>')((ip_address + '/32').encode('utf-8'))
|
||||||
).first()
|
).first()
|
||||||
if not network:
|
if not network:
|
||||||
# 尝试精确匹配 network.cidr
|
|
||||||
for net in db.query(Network).all():
|
for net in db.query(Network).all():
|
||||||
try:
|
try:
|
||||||
if ip_address in ipaddress.ip_network(net.cidr, strict=False):
|
if ip_address in ipaddress.ip_network(net.cidr, strict=False):
|
||||||
@@ -342,11 +466,15 @@ class EnhancedScanService:
|
|||||||
if hostname:
|
if hostname:
|
||||||
db_ip.hostname = hostname
|
db_ip.hostname = hostname
|
||||||
|
|
||||||
# 更新厂商信息
|
# 更新厂商信息(即使没有 MAC,也尝试用现有 MAC 补全)
|
||||||
vendor = scan_result.get('vendor')
|
vendor = scan_result.get('vendor')
|
||||||
if vendor:
|
if vendor:
|
||||||
db_ip.vendor = vendor
|
db_ip.vendor = vendor
|
||||||
|
|
||||||
|
# 如果没有拿到 vendor 但有 MAC,用现有 MAC 补全厂商
|
||||||
|
if not db_ip.vendor and db_ip.mac_address:
|
||||||
|
db_ip.vendor = EnhancedScanService.get_mac_vendor(db_ip.mac_address)
|
||||||
|
|
||||||
# 更新最后发现时间
|
# 更新最后发现时间
|
||||||
if scan_result.get('success'):
|
if scan_result.get('success'):
|
||||||
db_ip.last_seen = datetime.utcnow()
|
db_ip.last_seen = datetime.utcnow()
|
||||||
@@ -360,6 +488,7 @@ class EnhancedScanService:
|
|||||||
mac_found_count = 0
|
mac_found_count = 0
|
||||||
hostname_found_count = 0
|
hostname_found_count = 0
|
||||||
created_count = 0
|
created_count = 0
|
||||||
|
vendor_found_count = 0
|
||||||
|
|
||||||
for result in scan_results:
|
for result in scan_results:
|
||||||
ip = EnhancedScanService.update_ip_from_scan_result(db, result)
|
ip = EnhancedScanService.update_ip_from_scan_result(db, result)
|
||||||
@@ -370,6 +499,8 @@ class EnhancedScanService:
|
|||||||
mac_found_count += 1
|
mac_found_count += 1
|
||||||
if result.get('hostname'):
|
if result.get('hostname'):
|
||||||
hostname_found_count += 1
|
hostname_found_count += 1
|
||||||
|
if result.get('vendor'):
|
||||||
|
vendor_found_count += 1
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -377,5 +508,6 @@ class EnhancedScanService:
|
|||||||
'online_count': online_count,
|
'online_count': online_count,
|
||||||
'mac_found_count': mac_found_count,
|
'mac_found_count': mac_found_count,
|
||||||
'hostname_found_count': hostname_found_count,
|
'hostname_found_count': hostname_found_count,
|
||||||
|
'vendor_found_count': vendor_found_count,
|
||||||
'total_scanned': len(scan_results)
|
'total_scanned': len(scan_results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ class NetworkService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def calculate_total_ips(cidr: str) -> int:
|
def calculate_total_ips(cidr: str) -> int:
|
||||||
"""计算网段的总IP数量"""
|
"""计算网段中实际分配的IP数量(排除网络地址和广播地址)"""
|
||||||
network = ipaddress.ip_network(cidr, strict=False)
|
network = ipaddress.ip_network(cidr, strict=False)
|
||||||
return network.num_addresses
|
return network.num_addresses - 2 if network.num_addresses > 2 else network.num_addresses
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_network_addresses(cidr: str) -> List[str]:
|
def get_network_addresses(cidr: str) -> List[str]:
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from io import BytesIO, StringIO
|
||||||
|
import csv
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.models.network import IPAddress, Network, IPStatus
|
||||||
|
from app.models.snmp import NetworkDevice, ARPEntry
|
||||||
|
from app.models.alert import Alert
|
||||||
|
|
||||||
|
|
||||||
|
class ReportExportService:
|
||||||
|
"""报表导出服务"""
|
||||||
|
|
||||||
|
# ========== CSV 导出 ==========
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_ip_addresses_csv(db: Session,
|
||||||
|
network_id: Optional[int] = None,
|
||||||
|
status: Optional[IPStatus] = None,
|
||||||
|
include_online_only: bool = False) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
导出IP地址表为CSV
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
network_id: 网段ID(可选,不指定则导出所有)
|
||||||
|
status: IP状态过滤
|
||||||
|
include_online_only: 仅导出在线IP
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(CSV内容字符串, 文件名)
|
||||||
|
"""
|
||||||
|
query = db.query(IPAddress)
|
||||||
|
|
||||||
|
if network_id:
|
||||||
|
query = query.filter(IPAddress.network_id == network_id)
|
||||||
|
if status:
|
||||||
|
query = query.filter(IPAddress.status == status)
|
||||||
|
if include_online_only:
|
||||||
|
query = query.filter(IPAddress.status == IPStatus.ONLINE)
|
||||||
|
|
||||||
|
ips = query.order_by(IPAddress.network_id, IPAddress.id).all()
|
||||||
|
|
||||||
|
output = StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
# 写入表头
|
||||||
|
writer.writerow([
|
||||||
|
'ID', '网段ID', 'IP地址', 'MAC地址', '主机名', '状态',
|
||||||
|
'使用者', '业务类型', '备注', '厂商',
|
||||||
|
'最后发现时间', '创建时间'
|
||||||
|
])
|
||||||
|
|
||||||
|
# 写入数据
|
||||||
|
for ip in ips:
|
||||||
|
writer.writerow([
|
||||||
|
ip.id,
|
||||||
|
ip.network_id,
|
||||||
|
ip.ip_address,
|
||||||
|
ip.mac_address or '',
|
||||||
|
ip.hostname or '',
|
||||||
|
ip.status.value,
|
||||||
|
ip.owner or '',
|
||||||
|
ip.business_type or '',
|
||||||
|
ip.notes or '',
|
||||||
|
ip.vendor or '',
|
||||||
|
ip.last_seen.strftime('%Y-%m-%d %H:%M:%S') if ip.last_seen else '',
|
||||||
|
ip.created_at.strftime('%Y-%m-%d %H:%M:%S') if ip.created_at else ''
|
||||||
|
])
|
||||||
|
|
||||||
|
csv_content = output.getvalue()
|
||||||
|
filename = f"ip_addresses_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
|
|
||||||
|
return csv_content, filename
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_networks_csv(db: Session) -> tuple[str, str]:
|
||||||
|
"""导出网段汇总CSV"""
|
||||||
|
networks = db.query(Network).order_by(Network.id).all()
|
||||||
|
|
||||||
|
output = StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
writer.writerow([
|
||||||
|
'ID', '网段CIDR', '名称', '描述', '分组',
|
||||||
|
'网关', 'VLAN', '总IP数', '已用IP数', '保留IP数',
|
||||||
|
'创建时间'
|
||||||
|
])
|
||||||
|
|
||||||
|
for net in networks:
|
||||||
|
writer.writerow([
|
||||||
|
net.id,
|
||||||
|
net.cidr,
|
||||||
|
net.name or '',
|
||||||
|
net.description or '',
|
||||||
|
net.group_name or '',
|
||||||
|
net.gateway or '',
|
||||||
|
net.vlan_id or '',
|
||||||
|
net.total_ips,
|
||||||
|
net.used_ips,
|
||||||
|
net.reserved_ips,
|
||||||
|
net.created_at.strftime('%Y-%m-%d %H:%M:%S') if net.created_at else ''
|
||||||
|
])
|
||||||
|
|
||||||
|
csv_content = output.getvalue()
|
||||||
|
filename = f"networks_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
|
|
||||||
|
return csv_content, filename
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_alerts_csv(db: Session, status: Optional[str] = None) -> tuple[str, str]:
|
||||||
|
"""导出告警CSV"""
|
||||||
|
query = db.query(Alert)
|
||||||
|
if status:
|
||||||
|
query = query.filter(Alert.status == status)
|
||||||
|
|
||||||
|
alerts = query.order_by(Alert.created_at.desc()).all()
|
||||||
|
|
||||||
|
output = StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
writer.writerow([
|
||||||
|
'ID', '告警类型', '严重程度', '状态', '标题',
|
||||||
|
'详细信息', '相关IP', '相关MAC', '创建时间'
|
||||||
|
])
|
||||||
|
|
||||||
|
for alert in alerts:
|
||||||
|
writer.writerow([
|
||||||
|
alert.id,
|
||||||
|
alert.alert_type.value,
|
||||||
|
alert.severity.value,
|
||||||
|
alert.status.value,
|
||||||
|
alert.title,
|
||||||
|
alert.message or '',
|
||||||
|
alert.ip_address_str or '',
|
||||||
|
alert.mac_address or '',
|
||||||
|
alert.created_at.strftime('%Y-%m-%d %H:%M:%S') if alert.created_at else ''
|
||||||
|
])
|
||||||
|
|
||||||
|
csv_content = output.getvalue()
|
||||||
|
filename = f"alerts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
|
|
||||||
|
return csv_content, filename
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_snmp_devices_csv(db: Session) -> tuple[str, str]:
|
||||||
|
"""导出SNMP设备CSV"""
|
||||||
|
devices = db.query(NetworkDevice).order_by(NetworkDevice.id).all()
|
||||||
|
|
||||||
|
output = StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
writer.writerow([
|
||||||
|
'ID', '设备名称', 'IP地址', '端口', '设备类型',
|
||||||
|
'厂商', '型号', '位置', '最后轮询时间', '创建时间'
|
||||||
|
])
|
||||||
|
|
||||||
|
for dev in devices:
|
||||||
|
writer.writerow([
|
||||||
|
dev.id,
|
||||||
|
dev.name,
|
||||||
|
dev.ip_address,
|
||||||
|
dev.port,
|
||||||
|
dev.device_type.value,
|
||||||
|
dev.vendor or '',
|
||||||
|
dev.model or '',
|
||||||
|
dev.location or '',
|
||||||
|
dev.last_polled_at.strftime('%Y-%m-%d %H:%M:%S') if dev.last_polled_at else '',
|
||||||
|
dev.created_at.strftime('%Y-%m-%d %H:%M:%S') if dev.created_at else ''
|
||||||
|
])
|
||||||
|
|
||||||
|
csv_content = output.getvalue()
|
||||||
|
filename = f"snmp_devices_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
|
|
||||||
|
return csv_content, filename
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_arp_table_csv(db: Session, device_id: Optional[int] = None) -> tuple[str, str]:
|
||||||
|
"""导出ARP表CSV"""
|
||||||
|
query = db.query(ARPEntry)
|
||||||
|
if device_id:
|
||||||
|
query = query.filter(ARPEntry.device_id == device_id)
|
||||||
|
|
||||||
|
entries = query.order_by(ARPEntry.device_id, ARPEntry.id).all()
|
||||||
|
|
||||||
|
output = StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
writer.writerow([
|
||||||
|
'ID', '设备ID', 'IP地址', 'MAC地址', '接口', '最后发现时间'
|
||||||
|
])
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
writer.writerow([
|
||||||
|
entry.id,
|
||||||
|
entry.device_id,
|
||||||
|
entry.ip_address,
|
||||||
|
entry.mac_address,
|
||||||
|
entry.interface or '',
|
||||||
|
entry.last_seen.strftime('%Y-%m-%d %H:%M:%S') if entry.last_seen else ''
|
||||||
|
])
|
||||||
|
|
||||||
|
csv_content = output.getvalue()
|
||||||
|
filename = f"arp_table_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
|
|
||||||
|
return csv_content, filename
|
||||||
|
|
||||||
|
# ========== 汇总统计报表 ==========
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_ipam_summary(db: Session) -> Dict[str, Any]:
|
||||||
|
"""获取IPAM系统汇总报表"""
|
||||||
|
# 网段统计
|
||||||
|
total_networks = db.query(Network).count()
|
||||||
|
total_ips = db.query(IPAddress).count()
|
||||||
|
online_ips = db.query(IPAddress).filter(IPAddress.status == IPStatus.ONLINE).count()
|
||||||
|
reserved_ips = db.query(IPAddress).filter(IPAddress.status == IPStatus.RESERVED).count()
|
||||||
|
|
||||||
|
# 带MAC/主机名的IP统计
|
||||||
|
with_mac = db.query(IPAddress).filter(IPAddress.mac_address.isnot(None)).count()
|
||||||
|
with_hostname = db.query(IPAddress).filter(IPAddress.hostname.isnot(None)).count()
|
||||||
|
|
||||||
|
# 网段使用率分布
|
||||||
|
networks = db.query(Network).all()
|
||||||
|
usage_distribution = {
|
||||||
|
'under_50': 0, # 50%以下
|
||||||
|
'50_70': 0, # 50%-70%
|
||||||
|
'70_90': 0, # 70%-90%
|
||||||
|
'over_90': 0 # 90%以上
|
||||||
|
}
|
||||||
|
|
||||||
|
for net in networks:
|
||||||
|
usage_rate = (net.used_ips / net.total_ips * 100) if net.total_ips > 0 else 0
|
||||||
|
if usage_rate < 50:
|
||||||
|
usage_distribution['under_50'] += 1
|
||||||
|
elif usage_rate < 70:
|
||||||
|
usage_distribution['50_70'] += 1
|
||||||
|
elif usage_rate < 90:
|
||||||
|
usage_distribution['70_90'] += 1
|
||||||
|
else:
|
||||||
|
usage_distribution['over_90'] += 1
|
||||||
|
|
||||||
|
# 告警统计
|
||||||
|
from app.models.alert import Alert, AlertStatus
|
||||||
|
active_alerts = db.query(Alert).filter(Alert.status == AlertStatus.ACTIVE).count()
|
||||||
|
resolved_alerts = db.query(Alert).filter(Alert.status == AlertStatus.RESOLVED).count()
|
||||||
|
|
||||||
|
# SNMP设备统计
|
||||||
|
snmp_devices = db.query(NetworkDevice).count()
|
||||||
|
active_devices = db.query(NetworkDevice).filter(NetworkDevice.last_polled_at.isnot(None)).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"total_networks": total_networks,
|
||||||
|
"total_ip_addresses": total_ips,
|
||||||
|
"online_ips": online_ips,
|
||||||
|
"reserved_ips": reserved_ips,
|
||||||
|
"available_ips": total_ips - online_ips - reserved_ips,
|
||||||
|
"online_rate": round(online_ips / total_ips * 100, 2) if total_ips > 0 else 0
|
||||||
|
},
|
||||||
|
"discovery": {
|
||||||
|
"with_mac_address": with_mac,
|
||||||
|
"with_hostname": with_hostname,
|
||||||
|
"mac_discovery_rate": round(with_mac / total_ips * 100, 2) if total_ips > 0 else 0
|
||||||
|
},
|
||||||
|
"network_usage_distribution": usage_distribution,
|
||||||
|
"alerts": {
|
||||||
|
"active": active_alerts,
|
||||||
|
"resolved": resolved_alerts
|
||||||
|
},
|
||||||
|
"snmp": {
|
||||||
|
"total_devices": snmp_devices,
|
||||||
|
"active_devices": active_devices
|
||||||
|
},
|
||||||
|
"generated_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ from sqlalchemy.orm import Session
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from pysnmp.hlapi import (
|
from pysnmp.hlapi.asyncio import (
|
||||||
SnmpEngine, CommunityData, UsmUserData,
|
SnmpEngine, CommunityData, UsmUserData,
|
||||||
UdpTransportTarget, ContextData,
|
UdpTransportTarget, ContextData,
|
||||||
ObjectType, ObjectIdentity,
|
ObjectType, ObjectIdentity,
|
||||||
@@ -117,7 +118,7 @@ class SNMPService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def test_connection(device: NetworkDevice, credential: SNMPCredential) -> Tuple[bool, Dict[str, Any]]:
|
async def test_connection(device: NetworkDevice, credential: SNMPCredential) -> Tuple[bool, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
测试 SNMP 连接
|
测试 SNMP 连接
|
||||||
返回: (成功, 设备信息字典)
|
返回: (成功, 设备信息字典)
|
||||||
@@ -131,11 +132,11 @@ class SNMPService:
|
|||||||
retries=credential.retries
|
retries=credential.retries
|
||||||
)
|
)
|
||||||
|
|
||||||
errorIndication, errorStatus, errorIndex, varBinds = next(
|
errorIndication, errorStatus, errorIndex, varBinds = await getCmd(
|
||||||
getCmd(SnmpEngine(), auth_data, transport, ContextData(),
|
SnmpEngine(), auth_data, transport, ContextData(),
|
||||||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_DESCR)),
|
ObjectType(ObjectIdentity(SNMPService.OID_SYS_DESCR)),
|
||||||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_NAME)),
|
ObjectType(ObjectIdentity(SNMPService.OID_SYS_NAME)),
|
||||||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_LOCATION)))
|
ObjectType(ObjectIdentity(SNMPService.OID_SYS_LOCATION))
|
||||||
)
|
)
|
||||||
|
|
||||||
if errorIndication:
|
if errorIndication:
|
||||||
@@ -162,7 +163,7 @@ class SNMPService:
|
|||||||
return False, {'error': str(e)}
|
return False, {'error': str(e)}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_arp_table(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
async def get_arp_table(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取 ARP 表 (IP -> MAC 映射)
|
获取 ARP 表 (IP -> MAC 映射)
|
||||||
"""
|
"""
|
||||||
@@ -178,61 +179,62 @@ class SNMPService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 使用 bulkCmd 获取 ARP 表
|
# 使用 bulkCmd 获取 ARP 表
|
||||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
errorIndication, errorStatus, errorIndex, varBindTable = await bulkCmd(
|
||||||
SnmpEngine(), auth_data, transport, ContextData(),
|
SnmpEngine(), auth_data, transport, ContextData(),
|
||||||
0, 50, # nonRepeaters, maxRepetitions
|
0, 50, # nonRepeaters, maxRepetitions
|
||||||
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_PHYS_ADDRESS)),
|
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_NET_ADDRESS)),
|
||||||
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_IF_INDEX)),
|
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_IF_INDEX)),
|
||||||
lexicographicMode=False
|
lexicographicMode=False
|
||||||
):
|
)
|
||||||
if errorIndication:
|
if errorIndication:
|
||||||
logger.error(f"SNMP 错误: {errorIndication}")
|
logger.error(f"SNMP 错误: {errorIndication}")
|
||||||
break
|
elif errorStatus:
|
||||||
|
logger.error(f"SNMP 错误: {errorStatus}")
|
||||||
|
else:
|
||||||
|
# varBindTable 是列表的列表,每行是一条 SNMP 响应记录
|
||||||
|
for varBinds in varBindTable:
|
||||||
|
# 解析结果
|
||||||
|
arp_data = {}
|
||||||
|
for varBind in varBinds:
|
||||||
|
oid = str(varBind[0])
|
||||||
|
value = varBind[1]
|
||||||
|
|
||||||
if errorStatus:
|
# 提取 IP 地址 (OID 后缀: ifIndex.IP)
|
||||||
logger.error(f"SNMP 错误: {errorStatus}")
|
parts = oid.split('.')
|
||||||
break
|
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:
|
||||||
arp_data = {}
|
mac = SNMPService._normalize_mac(value)
|
||||||
for varBind in varBinds:
|
if mac:
|
||||||
oid = str(varBind[0])
|
if ip_address not in arp_data:
|
||||||
value = varBind[1]
|
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
|
||||||
|
|
||||||
# 提取 IP 地址 (OID 后缀: ifIndex.IP)
|
# 转换为列表
|
||||||
parts = oid.split('.')
|
now = datetime.utcnow()
|
||||||
if len(parts) >= 4:
|
for ip, data in arp_data.items():
|
||||||
# 从 OID 中提取 IP 地址
|
if 'mac_address' in data:
|
||||||
ip_parts = parts[-4:]
|
entry = {
|
||||||
ip_address = '.'.join(ip_parts)
|
'ip_address': ip,
|
||||||
|
'mac_address': data.get('mac_address'),
|
||||||
if SNMPService.OID_IP_NET_TO_MEDIA_PHYS_ADDRESS in oid:
|
'interface': str(data.get('if_index', '')),
|
||||||
mac = SNMPService._normalize_mac(value)
|
'device_id': device.id,
|
||||||
if mac:
|
'last_seen': now.isoformat(),
|
||||||
if ip_address not in arp_data:
|
'discovered_at': now.isoformat()
|
||||||
arp_data[ip_address] = {}
|
}
|
||||||
arp_data[ip_address]['mac_address'] = mac
|
arp_entries.append(entry)
|
||||||
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:
|
for entry_data in arp_entries:
|
||||||
# 查找是否已存在
|
# 查找是否已存在
|
||||||
existing = db.query(ARPEntry).filter(
|
existing = db.query(ARPEntry).filter(
|
||||||
@@ -256,16 +258,18 @@ class SNMPService:
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 更新 IP 资产台账中的 MAC 地址
|
# 更新 IP 资产台账中的 MAC 地址和厂商信息
|
||||||
for entry in arp_entries:
|
for entry in arp_entries:
|
||||||
from app.models.network import IPAddress as IPAddressModel
|
from app.models.network import IPAddress as IPAddressModel
|
||||||
ip_addr = db.query(IPAddressModel).filter(
|
ip_addr = db.query(IPAddressModel).filter(
|
||||||
IPAddressModel.ip_address == entry['ip_address']
|
IPAddressModel.ip_address == entry['ip_address']
|
||||||
).first()
|
).first()
|
||||||
if ip_addr and not ip_addr.mac_address:
|
if ip_addr:
|
||||||
ip_addr.mac_address = entry['mac_address']
|
if not ip_addr.mac_address:
|
||||||
# 识别厂商
|
ip_addr.mac_address = entry['mac_address']
|
||||||
ip_addr.vendor = EnhancedScanService.get_mac_vendor(entry['mac_address'])
|
# 始终尝试补全厂商(有 MAC 但没厂商时也需要)
|
||||||
|
if not ip_addr.vendor and ip_addr.mac_address:
|
||||||
|
ip_addr.vendor = EnhancedScanService.get_mac_vendor(ip_addr.mac_address)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
device.last_successful_poll = now
|
device.last_successful_poll = now
|
||||||
@@ -279,7 +283,7 @@ class SNMPService:
|
|||||||
return arp_entries
|
return arp_entries
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_mac_address_table(device: NetworkDevice, credential: SNMPCredential, db: Session, vlan_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
async def get_mac_address_table(device: NetworkDevice, credential: SNMPCredential, db: Session, vlan_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取 MAC 地址表 (MAC -> 端口 映射)
|
获取 MAC 地址表 (MAC -> 端口 映射)
|
||||||
"""
|
"""
|
||||||
@@ -295,39 +299,39 @@ class SNMPService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 获取 MAC 地址表
|
# 获取 MAC 地址表
|
||||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
errorIndication, errorStatus, errorIndex, varBindTable = await bulkCmd(
|
||||||
SnmpEngine(), auth_data, transport, ContextData(),
|
SnmpEngine(), auth_data, transport, ContextData(),
|
||||||
0, 100,
|
0, 100,
|
||||||
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_PORT)),
|
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_PORT)),
|
||||||
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_STATUS)),
|
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_STATUS)),
|
||||||
lexicographicMode=False
|
lexicographicMode=False
|
||||||
):
|
)
|
||||||
if errorIndication:
|
if errorIndication:
|
||||||
break
|
logger.error(f"获取 MAC 地址表失败: {errorIndication}")
|
||||||
|
elif errorStatus:
|
||||||
|
logger.error(f"获取 MAC 地址表失败: {errorStatus}")
|
||||||
|
else:
|
||||||
|
for varBinds in varBindTable:
|
||||||
|
for varBind in varBinds:
|
||||||
|
oid = str(varBind[0])
|
||||||
|
value = varBind[1]
|
||||||
|
|
||||||
if errorStatus:
|
# OID 后缀是 MAC 地址: .a.b.c.d.e.f
|
||||||
break
|
parts = oid.split('.')
|
||||||
|
if len(parts) >= 6:
|
||||||
|
mac_parts = parts[-6:]
|
||||||
|
mac_address = ':'.join(f'{int(p):02X}' for p in mac_parts)
|
||||||
|
|
||||||
for varBind in varBinds:
|
if SNMPService.OID_DOT1D_TP_FDB_PORT in oid:
|
||||||
oid = str(varBind[0])
|
port = int(value) if isinstance(value, Integer32) else None
|
||||||
value = varBind[1]
|
|
||||||
|
|
||||||
# OID 后缀是 MAC 地址: .a.b.c.d.e.f
|
entry = {
|
||||||
parts = oid.split('.')
|
'mac_address': mac_address,
|
||||||
if len(parts) >= 6:
|
'port_number': port,
|
||||||
mac_parts = parts[-6:]
|
'device_id': device.id,
|
||||||
mac_address = ':'.join(f'{int(p):02X}' for p in mac_parts)
|
'vlan_id': vlan_id
|
||||||
|
}
|
||||||
if SNMPService.OID_DOT1D_TP_FDB_PORT in oid:
|
mac_entries.append(entry)
|
||||||
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()
|
now = datetime.utcnow()
|
||||||
@@ -359,7 +363,7 @@ class SNMPService:
|
|||||||
return mac_entries
|
return mac_entries
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_interfaces(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
async def get_interfaces(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取设备接口信息
|
获取设备接口信息
|
||||||
"""
|
"""
|
||||||
@@ -377,7 +381,7 @@ class SNMPService:
|
|||||||
# 获取接口信息
|
# 获取接口信息
|
||||||
interface_data = {}
|
interface_data = {}
|
||||||
|
|
||||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
errorIndication, errorStatus, errorIndex, varBindTable = await bulkCmd(
|
||||||
SnmpEngine(), auth_data, transport, ContextData(),
|
SnmpEngine(), auth_data, transport, ContextData(),
|
||||||
0, 100,
|
0, 100,
|
||||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_DESCR)),
|
ObjectType(ObjectIdentity(SNMPService.OID_IF_DESCR)),
|
||||||
@@ -388,35 +392,34 @@ class SNMPService:
|
|||||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_ADMIN_STATUS)),
|
ObjectType(ObjectIdentity(SNMPService.OID_IF_ADMIN_STATUS)),
|
||||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_OPER_STATUS)),
|
ObjectType(ObjectIdentity(SNMPService.OID_IF_OPER_STATUS)),
|
||||||
lexicographicMode=False
|
lexicographicMode=False
|
||||||
):
|
)
|
||||||
if errorIndication or errorStatus:
|
if not errorIndication and not errorStatus:
|
||||||
break
|
for varBinds in varBindTable:
|
||||||
|
for varBind in varBinds:
|
||||||
|
oid = str(varBind[0])
|
||||||
|
value = varBind[1]
|
||||||
|
parts = oid.split('.')
|
||||||
|
if_index = parts[-1]
|
||||||
|
|
||||||
for varBind in varBinds:
|
if if_index not in interface_data:
|
||||||
oid = str(varBind[0])
|
interface_data[if_index] = {'if_index': int(if_index)}
|
||||||
value = varBind[1]
|
|
||||||
parts = oid.split('.')
|
|
||||||
if_index = parts[-1]
|
|
||||||
|
|
||||||
if if_index not in interface_data:
|
if SNMPService.OID_IF_DESCR in oid:
|
||||||
interface_data[if_index] = {'if_index': int(if_index)}
|
interface_data[if_index]['if_descr'] = value.prettyPrint()
|
||||||
|
elif SNMPService.OID_IF_TYPE in oid:
|
||||||
if SNMPService.OID_IF_DESCR in oid:
|
interface_data[if_index]['if_type'] = str(value)
|
||||||
interface_data[if_index]['if_descr'] = value.prettyPrint()
|
elif SNMPService.OID_IF_MTU in oid:
|
||||||
elif SNMPService.OID_IF_TYPE in oid:
|
interface_data[if_index]['if_mtu'] = int(value) if isinstance(value, Integer32) else None
|
||||||
interface_data[if_index]['if_type'] = str(value)
|
elif SNMPService.OID_IF_SPEED in oid:
|
||||||
elif SNMPService.OID_IF_MTU in oid:
|
interface_data[if_index]['if_speed'] = int(value) if isinstance(value, (Integer32, Gauge32)) else None
|
||||||
interface_data[if_index]['if_mtu'] = int(value) if isinstance(value, Integer32) else None
|
elif SNMPService.OID_IF_PHYS_ADDRESS in oid:
|
||||||
elif SNMPService.OID_IF_SPEED in oid:
|
interface_data[if_index]['if_phys_address'] = SNMPService._normalize_mac(value)
|
||||||
interface_data[if_index]['if_speed'] = int(value) if isinstance(value, (Integer32, Gauge32)) else None
|
elif SNMPService.OID_IF_ADMIN_STATUS in oid:
|
||||||
elif SNMPService.OID_IF_PHYS_ADDRESS in oid:
|
status_map = {1: 'up', 2: 'down', 3: 'testing'}
|
||||||
interface_data[if_index]['if_phys_address'] = SNMPService._normalize_mac(value)
|
interface_data[if_index]['if_admin_status'] = status_map.get(int(value), 'unknown')
|
||||||
elif SNMPService.OID_IF_ADMIN_STATUS in oid:
|
elif SNMPService.OID_IF_OPER_STATUS in oid:
|
||||||
status_map = {1: 'up', 2: 'down', 3: 'testing'}
|
status_map = {1: 'up', 2: 'down', 3: 'testing'}
|
||||||
interface_data[if_index]['if_admin_status'] = status_map.get(int(value), 'unknown')
|
interface_data[if_index]['if_oper_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()
|
now = datetime.utcnow()
|
||||||
@@ -483,7 +486,7 @@ class SNMPService:
|
|||||||
return device
|
return device
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def poll_device(db: Session, device_id: int) -> Dict[str, Any]:
|
async def poll_device(db: Session, device_id: int) -> Dict[str, Any]:
|
||||||
"""轮询设备,获取所有信息"""
|
"""轮询设备,获取所有信息"""
|
||||||
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
||||||
if not device:
|
if not device:
|
||||||
@@ -503,17 +506,17 @@ class SNMPService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 获取 ARP 表
|
# 获取 ARP 表
|
||||||
arp_entries = SNMPService.get_arp_table(device, credential, db)
|
arp_entries = await SNMPService.get_arp_table(device, credential, db)
|
||||||
result['arp_entries'] = arp_entries
|
result['arp_entries'] = arp_entries
|
||||||
result['arp_count'] = len(arp_entries)
|
result['arp_count'] = len(arp_entries)
|
||||||
|
|
||||||
# 获取 MAC 地址表
|
# 获取 MAC 地址表
|
||||||
mac_entries = SNMPService.get_mac_address_table(device, credential, db)
|
mac_entries = await SNMPService.get_mac_address_table(device, credential, db)
|
||||||
result['mac_entries'] = mac_entries
|
result['mac_entries'] = mac_entries
|
||||||
result['mac_count'] = len(mac_entries)
|
result['mac_count'] = len(mac_entries)
|
||||||
|
|
||||||
# 获取接口信息
|
# 获取接口信息
|
||||||
interfaces = SNMPService.get_interfaces(device, credential, db)
|
interfaces = await SNMPService.get_interfaces(device, credential, db)
|
||||||
result['interfaces'] = interfaces
|
result['interfaces'] = interfaces
|
||||||
result['interface_count'] = len(interfaces)
|
result['interface_count'] = len(interfaces)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -21,6 +21,7 @@ def register_tasks(celery_app):
|
|||||||
enable_ping: bool = True,
|
enable_ping: bool = True,
|
||||||
enable_arp: bool = True,
|
enable_arp: bool = True,
|
||||||
enable_dns: bool = True,
|
enable_dns: bool = True,
|
||||||
|
enable_netbios: bool = True,
|
||||||
timeout: int = 2):
|
timeout: int = 2):
|
||||||
"""
|
"""
|
||||||
Celery 异步任务:扫描指定网段
|
Celery 异步任务:扫描指定网段
|
||||||
@@ -54,6 +55,7 @@ def register_tasks(celery_app):
|
|||||||
enable_ping=enable_ping,
|
enable_ping=enable_ping,
|
||||||
enable_arp=enable_arp,
|
enable_arp=enable_arp,
|
||||||
enable_dns=enable_dns,
|
enable_dns=enable_dns,
|
||||||
|
enable_netbios=enable_netbios,
|
||||||
timeout=timeout
|
timeout=timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,7 +122,8 @@ def register_tasks(celery_app):
|
|||||||
@celery_app.task(bind=True)
|
@celery_app.task(bind=True)
|
||||||
def full_network_scan(self, enable_ping: bool = True,
|
def full_network_scan(self, enable_ping: bool = True,
|
||||||
enable_arp: bool = True,
|
enable_arp: bool = True,
|
||||||
enable_dns: bool = True):
|
enable_dns: bool = True,
|
||||||
|
enable_netbios: bool = True):
|
||||||
"""
|
"""
|
||||||
Celery 定时任务:扫描所有网段
|
Celery 定时任务:扫描所有网段
|
||||||
"""
|
"""
|
||||||
@@ -144,7 +147,8 @@ def register_tasks(celery_app):
|
|||||||
network.cidr,
|
network.cidr,
|
||||||
enable_ping=enable_ping,
|
enable_ping=enable_ping,
|
||||||
enable_arp=enable_arp,
|
enable_arp=enable_arp,
|
||||||
enable_dns=enable_dns
|
enable_dns=enable_dns,
|
||||||
|
enable_netbios=enable_netbios
|
||||||
)
|
)
|
||||||
|
|
||||||
stats = EnhancedScanService.bulk_update_ips_from_scan(db, results)
|
stats = EnhancedScanService.bulk_update_ips_from_scan(db, results)
|
||||||
@@ -191,7 +195,10 @@ def register_tasks(celery_app):
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
for ip in ip_addresses:
|
for ip in ip_addresses:
|
||||||
result = EnhancedScanService.comprehensive_scan(ip, enable_ping=True, enable_arp=True, enable_dns=False)
|
result = EnhancedScanService.comprehensive_scan(
|
||||||
|
ip, enable_ping=True, enable_arp=True, enable_dns=False,
|
||||||
|
enable_netbios=True
|
||||||
|
)
|
||||||
EnhancedScanService.update_ip_from_scan_result(db, result)
|
EnhancedScanService.update_ip_from_scan_result(db, result)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# Python 3.10+ 兼容版本
|
||||||
fastapi==0.110.0
|
fastapi==0.110.0
|
||||||
uvicorn[standard]==0.27.1
|
uvicorn[standard]==0.27.1
|
||||||
sqlalchemy==2.0.28
|
sqlalchemy==2.0.28
|
||||||
@@ -9,8 +10,18 @@ celery==5.3.6
|
|||||||
redis==5.0.3
|
redis==5.0.3
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
alembic==1.13.1
|
alembic==1.13.1
|
||||||
pysnmp==7.1.15
|
# 注意: pysnmp 7.x 与 Python 3.10+/asyncio 存在兼容性问题
|
||||||
|
# 使用 lextudio 维护的分支(向后兼容,支持 Python 3.6+)
|
||||||
|
pysnmp-lextudio==5.0.31
|
||||||
|
# pyasn1 0.5.0+ 移除了 compat.octets 模块,与 pysnmp 不兼容
|
||||||
|
pyasn1==0.4.8
|
||||||
|
pyasn1-modules==0.2.8
|
||||||
scapy==2.5.0
|
scapy==2.5.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
# python-jose 3.5.0 需要 pyasn1>=0.5.0,与 pysnmp 冲突
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
bcrypt==3.2.2
|
||||||
|
# typing-extensions 提供 Python 3.10+ 的向后兼容支持
|
||||||
|
typing-extensions>=4.5.0
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# IPAM 运维脚本
|
||||||
|
|
||||||
|
存放 IPAM 平台的一次性运维脚本。所有脚本都假定从 `backend/` 目录执行,且 venv 已激活。
|
||||||
|
|
||||||
|
## 回填厂商 / 主机名 — `backfill_vendor.py`
|
||||||
|
|
||||||
|
### 用途
|
||||||
|
|
||||||
|
在某些情况下(升级前扫描失败、OUI 库没装、依赖 bug 等)IP 表里 `mac_address` 有值但 `vendor`/`hostname` 是空。本脚本会:
|
||||||
|
|
||||||
|
- 找出所有 `mac_address IS NOT NULL AND vendor IS NULL` 的 IP
|
||||||
|
- 调用 `EnhancedScanService.get_mac_vendor()` 重新识别厂商(基于 IEEE OUI 数据库)
|
||||||
|
- 可选:调用 `reverse_dns_lookup()` 回填主机名
|
||||||
|
- 支持 dry-run 模式(只统计不写库)
|
||||||
|
|
||||||
|
### 用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 确保依赖装了(首次或升级后必做)
|
||||||
|
pip install mac-vendor-lookup
|
||||||
|
|
||||||
|
# 2. 干跑:只统计能识别的 IP 数量,不写库
|
||||||
|
python scripts/backfill_vendor.py --dry-run
|
||||||
|
|
||||||
|
# 3. 实际回填
|
||||||
|
python scripts/backfill_vendor.py
|
||||||
|
|
||||||
|
# 4. 同时回填主机名(用反向 DNS,会慢一些)
|
||||||
|
python scripts/backfill_vendor.py --with-hostname
|
||||||
|
|
||||||
|
# 5. 加 --yes 跳过确认提示(cron 用)
|
||||||
|
python scripts/backfill_vendor.py --yes
|
||||||
|
|
||||||
|
# 6. 限制处理数量(测试用)
|
||||||
|
python scripts/backfill_vendor.py --dry-run --limit 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `--dry-run` | 只统计,不写库 |
|
||||||
|
| `--with-hostname` | 同时回填 hostname(DNS 解析) |
|
||||||
|
| `--timeout N` | DNS 解析超时秒数(默认 2) |
|
||||||
|
| `--limit N` | 最多处理 N 条,0 = 不限 |
|
||||||
|
| `--yes` / `-y` | 跳过确认提示 |
|
||||||
|
|
||||||
|
### 常见场景
|
||||||
|
|
||||||
|
**场景 1:升级后 IP 厂商显示 -**
|
||||||
|
```bash
|
||||||
|
pip install mac-vendor-lookup # 装新依赖
|
||||||
|
python scripts/backfill_vendor.py --dry-run # 先看数量
|
||||||
|
python scripts/backfill_vendor.py # 实际回填
|
||||||
|
```
|
||||||
|
|
||||||
|
**场景 2:扫描能拿到 MAC,但扫描时 vendor 字段没写**
|
||||||
|
说明扫描代码逻辑有 bug,应该改 `enhanced_scan_service.py` 的 `update_ip_from_scan_result()` 而不是用本脚本。脚本只是数据修复。
|
||||||
|
|
||||||
|
**场景 3:所有 IP 都没有 MAC**
|
||||||
|
说明扫描本身没成功,根因在网络层(防火墙、扫描不到、ICMP/ARP 都被禁)。需要先解决扫描链路问题。
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
- `mac-vendor-lookup`(IEEE OUI 官方数据库,约 17 万条记录)
|
||||||
|
- 项目本身的依赖(FastAPI、SQLAlchemy、PyMySQL 等)
|
||||||
|
- 可选:`pynmblookup`(NetBIOS 主机名发现,仅 Windows 设备需要)
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- 脚本会**直接写库**,建议先 dry-run
|
||||||
|
- hostname 反向 DNS 可能比较慢(取决于目标设备是否配置了 PTR 记录)
|
||||||
|
- 不会改写已经存在的 vendor/hostname(只填空值)
|
||||||
|
- 不影响扫描逻辑本身(不会自动重新扫描)
|
||||||
|
|
||||||
|
### 故障排查
|
||||||
|
|
||||||
|
如果脚本运行时报 `ImportError: No module named 'app'`,说明 `sys.path` 没找到 backend 目录。在 backend 目录里执行:
|
||||||
|
```bash
|
||||||
|
cd /root/ipam/backend
|
||||||
|
python scripts/backfill_vendor.py
|
||||||
|
```
|
||||||
|
|
||||||
|
如果脚本运行后 `未识别 = 候选 IP 总数`,说明 mac_vendor_lookup 库没装或加载失败:
|
||||||
|
```bash
|
||||||
|
pip show mac-vendor-lookup # 检查库是否装好
|
||||||
|
python -c "from mac_vendor_lookup import MacLookup; print('OK')" # 测试导入
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 OUI 数据库太旧(库自带的 IEEE OUI 文件是 2022 年的快照),新厂商识别不到,可以更新:
|
||||||
|
```bash
|
||||||
|
pip install -U mac-vendor-lookup
|
||||||
|
# 或下载最新 IEEE OUI 文件
|
||||||
|
python -c "from mac_vendor_lookup import MacLookup; MacLookup().update_vendors()"
|
||||||
|
```
|
||||||
Executable
+177
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
回填 IP 厂商信息(vendor)
|
||||||
|
|
||||||
|
对所有 mac_address 有值但 vendor 为空的 IP,调用 EnhancedScanService.get_mac_vendor
|
||||||
|
回填厂商名。基于 IEEE OUI 官方数据库(mac-vendor-lookup 包),无需重新扫描。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
# 先在 git pull 之后确保依赖装了
|
||||||
|
pip install mac-vendor-lookup
|
||||||
|
|
||||||
|
# 干跑(只看不写)
|
||||||
|
python scripts/backfill_vendor.py --dry-run
|
||||||
|
|
||||||
|
# 实际写入
|
||||||
|
python scripts/backfill_vendor.py
|
||||||
|
|
||||||
|
# 同时回填主机名(反向 DNS 解析)
|
||||||
|
python scripts/backfill_vendor.py --with-hostname
|
||||||
|
|
||||||
|
参数:
|
||||||
|
--dry-run 只统计能识别的 IP 数量,不写库
|
||||||
|
--with-hostname 同时回填 hostname(需要 DNS 解析,会比较慢)
|
||||||
|
--timeout N DNS 解析超时秒数(默认 2)
|
||||||
|
--limit N 最多处理 N 条(用于测试)
|
||||||
|
--yes 跳过确认提示
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 让脚本能 import app.* 模块
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
BACKEND_DIR = os.path.dirname(SCRIPT_DIR)
|
||||||
|
sys.path.insert(0, BACKEND_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="回填 IP 表的 vendor / hostname 字段",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="只统计,不写库")
|
||||||
|
p.add_argument("--with-hostname", action="store_true", help="同时回填主机名(反向 DNS 解析)")
|
||||||
|
p.add_argument("--timeout", type=int, default=2, help="DNS 解析超时秒数(默认 2)")
|
||||||
|
p.add_argument("--limit", type=int, default=0, help="最多处理 N 条,0 表示不限")
|
||||||
|
p.add_argument("--yes", "-y", action="store_true", help="跳过确认提示")
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def confirm(prompt: str, skip: bool) -> bool:
|
||||||
|
if skip:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
ans = input(f"{prompt} [y/N] ").strip().lower()
|
||||||
|
return ans in ("y", "yes")
|
||||||
|
except EOFError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
# 延迟 import,确保 sys.path 设好
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.network import IPAddress
|
||||||
|
from app.services.enhanced_scan_service import EnhancedScanService
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 找出 mac_address 有值但 vendor 为空的 IP
|
||||||
|
query = db.query(IPAddress).filter(
|
||||||
|
IPAddress.mac_address.isnot(None),
|
||||||
|
IPAddress.vendor.is_(None),
|
||||||
|
)
|
||||||
|
if args.limit > 0:
|
||||||
|
query = query.limit(args.limit)
|
||||||
|
candidates = query.all()
|
||||||
|
total_candidates = len(candidates)
|
||||||
|
|
||||||
|
print(f"[扫描] 找到 {total_candidates} 个 mac_address 有值但 vendor 为空的 IP")
|
||||||
|
|
||||||
|
if total_candidates == 0:
|
||||||
|
print("✅ 没有需要回填的 IP")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 统计
|
||||||
|
filled_vendor = 0
|
||||||
|
skipped_vendor = 0
|
||||||
|
filled_hostname = 0
|
||||||
|
skipped_hostname = 0
|
||||||
|
errors = 0
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
for i, ip in enumerate(candidates, 1):
|
||||||
|
mac = ip.mac_address
|
||||||
|
vendor = EnhancedScanService.get_mac_vendor(mac)
|
||||||
|
if vendor:
|
||||||
|
if not args.dry_run:
|
||||||
|
ip.vendor = vendor
|
||||||
|
filled_vendor += 1
|
||||||
|
else:
|
||||||
|
skipped_vendor += 1
|
||||||
|
|
||||||
|
# 反向 DNS(可选)
|
||||||
|
hostname = None
|
||||||
|
if args.with_hostname:
|
||||||
|
try:
|
||||||
|
hostname = EnhancedScanService.reverse_dns_lookup(ip.ip_address, timeout=args.timeout)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [WARN] {ip.ip_address} DNS 解析失败: {e}")
|
||||||
|
errors += 1
|
||||||
|
if hostname:
|
||||||
|
if not args.dry_run:
|
||||||
|
ip.hostname = hostname
|
||||||
|
filled_hostname += 1
|
||||||
|
else:
|
||||||
|
skipped_hostname += 1
|
||||||
|
|
||||||
|
# 每 100 条打印进度
|
||||||
|
if i % 100 == 0 or i == total_candidates:
|
||||||
|
elapsed = time.time() - start
|
||||||
|
rate = i / elapsed if elapsed > 0 else 0
|
||||||
|
eta = (total_candidates - i) / rate if rate > 0 else 0
|
||||||
|
print(
|
||||||
|
f" 进度: {i}/{total_candidates} "
|
||||||
|
f"已识别 vendor={filled_vendor}, 未识别={skipped_vendor} "
|
||||||
|
f"elapsed={elapsed:.1f}s, ETA={eta:.1f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 提交(如果非 dry-run)
|
||||||
|
if not args.dry_run:
|
||||||
|
if not confirm(f"确认写入数据库?将更新 {filled_vendor} 个 IP 的 vendor 字段"
|
||||||
|
+ (f" + {filled_hostname} 个 IP 的 hostname 字段" if args.with_hostname else ""),
|
||||||
|
args.yes):
|
||||||
|
print("已取消")
|
||||||
|
db.rollback()
|
||||||
|
return 1
|
||||||
|
db.commit()
|
||||||
|
print()
|
||||||
|
print("✅ 已提交数据库")
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
print(f"[DRY-RUN] 不写库;如需执行去掉 --dry-run")
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
print()
|
||||||
|
print("=== 汇总 ===")
|
||||||
|
print(f" 候选 IP 总数: {total_candidates}")
|
||||||
|
print(f" 识别到厂商: {filled_vendor}")
|
||||||
|
print(f" 未识别(OUI 未收录或格式异常): {skipped_vendor}")
|
||||||
|
if args.with_hostname:
|
||||||
|
print(f" 解析到主机名: {filled_hostname}")
|
||||||
|
print(f" DNS 解析失败或超时: {skipped_hostname}")
|
||||||
|
print(f" 异常次数: {errors}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[中断] 已回滚未提交的修改")
|
||||||
|
db.rollback()
|
||||||
|
return 130
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[ERROR] {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
db.rollback()
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
修复已有网段的 total_ips 字段:将 num_addresses 改为 hosts 数量(排除网络地址和广播地址)
|
||||||
|
|
||||||
|
用法: cd /root/ipam/backend && source venv/bin/activate && python scripts/fix_network_total_ips.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 确保能找到 app 模块
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.network import Network
|
||||||
|
from app.services.network_service import NetworkService
|
||||||
|
|
||||||
|
|
||||||
|
def fix_total_ips():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
networks = db.query(Network).all()
|
||||||
|
fixed = 0
|
||||||
|
for net in networks:
|
||||||
|
correct = NetworkService.calculate_total_ips(net.cidr)
|
||||||
|
actual_hosts = len(list(NetworkService.get_network_addresses(net.cidr)))
|
||||||
|
if net.total_ips != correct:
|
||||||
|
print(f" [{net.cidr:20s}] {net.total_ips:>5} -> {correct:>5} (hosts={actual_hosts})")
|
||||||
|
net.total_ips = correct
|
||||||
|
fixed += 1
|
||||||
|
db.commit()
|
||||||
|
print(f"\n✅ 已修复 {fixed} 个网段")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"❌ 错误: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
fix_total_ips()
|
||||||
@@ -4,9 +4,10 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "node node_modules/vite/bin/vite.js",
|
||||||
"build": "vite build",
|
"dev:clean": "rm -rf node_modules/.vite && node node_modules/vite/bin/vite.js --force",
|
||||||
"preview": "vite preview"
|
"build": "node node_modules/vite/bin/vite.js build",
|
||||||
|
"preview": "node node_modules/vite/bin/vite.js preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
|||||||
@@ -63,20 +63,29 @@ export const scanApi = {
|
|||||||
return api.post(`/scan/ping/${id}`)
|
return api.post(`/scan/ping/${id}`)
|
||||||
},
|
},
|
||||||
// 综合扫描网段
|
// 综合扫描网段
|
||||||
// options: { enable_ping, enable_arp, enable_dns, timeout }
|
// options: { enable_ping, enable_arp, enable_dns, enable_netbios, timeout }
|
||||||
// 单次调用覆盖全局 30s 超时,因为 /24 网段扫描要数分钟
|
// 单次调用覆盖全局 30s 超时,因为 /24 网段扫描要数分钟
|
||||||
comprehensiveScan(id, options = {}) {
|
comprehensiveScan(id, options = {}) {
|
||||||
const {
|
const {
|
||||||
enable_ping = true,
|
enable_ping = true,
|
||||||
enable_arp = true,
|
enable_arp = true,
|
||||||
enable_dns = true,
|
enable_dns = true,
|
||||||
|
enable_netbios = true,
|
||||||
timeout = 2
|
timeout = 2
|
||||||
} = options
|
} = options
|
||||||
return api.post(`/scan/comprehensive/${id}`, {}, {
|
return api.post(`/scan/comprehensive/${id}`, {}, {
|
||||||
params: { enable_ping, enable_arp, enable_dns, timeout },
|
params: { enable_ping, enable_arp, enable_dns, enable_netbios, timeout },
|
||||||
timeout: 600000 // 10 分钟,足以应付 /16 级别网段
|
timeout: 600000 // 10 分钟,足以应付 /16 级别网段
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
// 批量补全厂商信息
|
||||||
|
batchFillVendor(params) {
|
||||||
|
return api.post('/scan/batch/vendor', {}, { params })
|
||||||
|
},
|
||||||
|
// 批量发现主机名
|
||||||
|
batchDiscoverHostname(params) {
|
||||||
|
return api.post('/scan/batch/hostname', {}, { params })
|
||||||
|
},
|
||||||
// 单个IP扫描
|
// 单个IP扫描
|
||||||
scanIP(ipAddress) {
|
scanIP(ipAddress) {
|
||||||
return api.post(`/scan/comprehensive/ip/${ipAddress}`)
|
return api.post(`/scan/comprehensive/ip/${ipAddress}`)
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* 统一时间格式化工具
|
||||||
|
*
|
||||||
|
* 背景:后端用 datetime.utcnow() 写入 UTC naive datetime。
|
||||||
|
* 修复后端后,schema 输出会带 +08:00 或 Z(Asia/Shanghai 或 UTC)时区标签。
|
||||||
|
* 本 helper:
|
||||||
|
* 1. 收到的字符串如果带时区标签(Z 或 +HH:MM),按字面解析
|
||||||
|
* 2. 如果无时区标签,假定为 UTC 解析(兼容老 endpoint 内联 dict 序列化)
|
||||||
|
* 3. 转 Asia/Shanghai (+08:00) 显示
|
||||||
|
* 4. 显示成 'YYYY-MM-DD HH:MM:SS' 字符串
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BUSINESS_TZ = 'Asia/Shanghai'
|
||||||
|
|
||||||
|
export function parseToBusiness (input) {
|
||||||
|
if (input == null || input === '') return null
|
||||||
|
let d
|
||||||
|
if (input instanceof Date) {
|
||||||
|
d = input
|
||||||
|
} else if (typeof input === 'number') {
|
||||||
|
d = new Date(input)
|
||||||
|
} else {
|
||||||
|
// 字符串:优先按 ISO 解析(含 Z / +HH:MM 则按对应时区)
|
||||||
|
// JS 原生 new Date() 对 'YYYY-MM-DD HH:MM:SS'(无 T,无时区)按本地时区解析,
|
||||||
|
// 对 'YYYY-MM-DDTHH:MM:SSZ' 等标准 ISO 8601 则按 UTC 解析。
|
||||||
|
const s = String(input).trim()
|
||||||
|
// 后端 schema 修复后输出 'YYYY-MM-DDTHH:MM:SS+08:00'(aware),
|
||||||
|
// 老 endpoint 内联 dict 输出 'YYYY-MM-DD HH:MM:SS'(naive UTC)。
|
||||||
|
// JS Date 无法区分后者的'naive UTC'和'naive 本地时间'。
|
||||||
|
// 但本项目所有 datetime 后端都是 datetime.utcnow() 写的,
|
||||||
|
// 所以 naive 也按 UTC 处理。
|
||||||
|
d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z')
|
||||||
|
}
|
||||||
|
if (isNaN(d.getTime())) return null
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把任意 datetime 输入格式化成 Asia/Shanghai 时区的 'YYYY-MM-DD HH:MM:SS'。
|
||||||
|
* 不做时区转换的纯字符串渲染请用 formatUtc。
|
||||||
|
*/
|
||||||
|
export function formatDateTime (input) {
|
||||||
|
if (input == null || input === '') return '-'
|
||||||
|
const d = parseToBusiness(input)
|
||||||
|
if (!d) return '-'
|
||||||
|
const opts = {
|
||||||
|
timeZone: BUSINESS_TZ,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', opts).format(d)
|
||||||
|
// zh-CN 格式默认是 "2026/07/23 14:31:19",替换成 "2026-07-23 14:31:19"
|
||||||
|
.replace(/\//g, '-')
|
||||||
|
} catch {
|
||||||
|
// 浏览器不支持 Intl 时区时回退:用本地时区手动格式化
|
||||||
|
return formatLocal(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅显示日期 'YYYY-MM-DD'。
|
||||||
|
*/
|
||||||
|
export function formatDate (input) {
|
||||||
|
if (input == null || input === '') return '-'
|
||||||
|
const d = parseToBusiness(input)
|
||||||
|
if (!d) return '-'
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: BUSINESS_TZ,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
}).format(d).replace(/\//g, '-')
|
||||||
|
} catch {
|
||||||
|
return formatLocal(d).substring(0, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅显示时间 'HH:MM' 或 'HH:MM:SS'(带 seconds 参数控制)。
|
||||||
|
*/
|
||||||
|
export function formatTime (input, { seconds = false } = {}) {
|
||||||
|
if (input == null || input === '') return '-'
|
||||||
|
const d = parseToBusiness(input)
|
||||||
|
if (!d) return '-'
|
||||||
|
const opts = {
|
||||||
|
timeZone: BUSINESS_TZ,
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||||
|
}
|
||||||
|
if (seconds) opts.second = '2-digit'
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', opts).format(d)
|
||||||
|
} catch {
|
||||||
|
return formatLocal(d).substring(11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户友好的相对时间描述("3 分钟前")。
|
||||||
|
*/
|
||||||
|
export function formatRelative (input) {
|
||||||
|
if (input == null || input === '') return '-'
|
||||||
|
const d = parseToBusiness(input)
|
||||||
|
if (!d) return '-'
|
||||||
|
const diff = (Date.now() - d.getTime()) / 1000
|
||||||
|
if (diff < 60) return '刚刚'
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
|
||||||
|
if (diff < 604800) return `${Math.floor(diff / 86400)} 天前`
|
||||||
|
return formatDateTime(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部:浏览器不支持时区时回退到本地格式化(业务时区手动转)
|
||||||
|
function formatLocal (d) {
|
||||||
|
// 用本地方法拿到 YYYY-MM-DD HH:MM:SS(按浏览器本地时区,可能错 8 小时但作为兜底)
|
||||||
|
const pad = n => String(n).padStart(2, '0')
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const mo = pad(d.getMonth() + 1)
|
||||||
|
const da = pad(d.getDate())
|
||||||
|
const h = pad(d.getHours())
|
||||||
|
const mi = pad(d.getMinutes())
|
||||||
|
const s = pad(d.getSeconds())
|
||||||
|
return `${y}-${mo}-${da} ${h}:${mi}:${s}`
|
||||||
|
}
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="created_at" label="时间" width="180">
|
<el-table-column prop="created_at" label="时间" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDate(scope.row.created_at) }}
|
{{ formatDateTime(scope.row.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
<el-table-column prop="owner" label="所有者" width="120" />
|
<el-table-column prop="owner" label="所有者" width="120" />
|
||||||
<el-table-column prop="created_at" label="添加时间" width="180">
|
<el-table-column prop="created_at" label="添加时间" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDate(scope.row.created_at) }}
|
{{ formatDateTime(scope.row.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="100" align="center">
|
<el-table-column label="操作" width="100" align="center">
|
||||||
@@ -238,6 +238,7 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { alertApi } from '@/api'
|
import { alertApi } from '@/api'
|
||||||
|
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detecting = ref(false)
|
const detecting = ref(false)
|
||||||
@@ -460,10 +461,7 @@ const getAlertTypeText = (type) => {
|
|||||||
return texts[type] || type
|
return texts[type] || type
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date) => {
|
// 旧 formatDate 已由 @/utils/datetime 统一替代
|
||||||
if (!date) return '-'
|
|
||||||
return new Date(date).toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadStats()
|
loadStats()
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
<el-icon><Search /></el-icon>
|
<el-icon><Search /></el-icon>
|
||||||
批量扫描
|
批量扫描
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button type="warning" @click="batchFillVendor" :loading="vendorLoading">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
补全厂商
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -103,7 +107,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="last_seen" label="最后发现" width="170">
|
<el-table-column prop="last_seen" label="最后发现" width="170">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.last_seen ? formatDate(scope.row.last_seen) : '-' }}
|
{{ scope.row.last_seen ? formatDateTime(scope.row.last_seen) : '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" align="center" fixed="right">
|
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||||
@@ -170,12 +174,14 @@ import { ref, reactive, onMounted, computed } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ipApi, networkApi, scanApi } from '@/api'
|
import { ipApi, networkApi, scanApi } from '@/api'
|
||||||
|
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const scanning = ref(false)
|
const scanning = ref(false)
|
||||||
|
const vendorLoading = ref(false)
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
|
|
||||||
const tableData = ref([])
|
const tableData = ref([])
|
||||||
@@ -278,6 +284,24 @@ const startBatchScan = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const batchFillVendor = async () => {
|
||||||
|
if (!filters.networkId) {
|
||||||
|
ElMessage.warning('请先选择网段')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vendorLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await scanApi.batchFillVendor({ network_id: filters.networkId })
|
||||||
|
ElMessage.success(res.message)
|
||||||
|
loadIPs()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('补全厂商失败')
|
||||||
|
console.error('补全厂商错误:', error)
|
||||||
|
} finally {
|
||||||
|
vendorLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const editIP = (row) => {
|
const editIP = (row) => {
|
||||||
ipForm.id = row.id
|
ipForm.id = row.id
|
||||||
ipForm.ip_address = row.ip_address
|
ipForm.ip_address = row.ip_address
|
||||||
@@ -327,10 +351,7 @@ const getStatusText = (status) => {
|
|||||||
return texts[status] || status
|
return texts[status] || status
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date) => {
|
// 旧 formatDate 已由 @/utils/datetime 统一替代
|
||||||
if (!date) return '-'
|
|
||||||
return new Date(date).toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadNetworks()
|
loadNetworks()
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDate(scope.row.created_at) }}
|
{{ formatDateTime(scope.row.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="280" align="center" fixed="right">
|
<el-table-column label="操作" width="280" align="center" fixed="right">
|
||||||
@@ -139,6 +139,7 @@ import { ref, reactive, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { networkApi, scanApi } from '@/api'
|
import { networkApi, scanApi } from '@/api'
|
||||||
|
import { formatDateTime, formatDate } from '@/utils/datetime'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -304,10 +305,7 @@ const getGroupTagType = (group) => {
|
|||||||
return types[group] || 'info'
|
return types[group] || 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date) => {
|
// 旧 formatDate 已由 @/utils/datetime 统一替代
|
||||||
if (!date) return '-'
|
|
||||||
return new Date(date).toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadNetworks()
|
loadNetworks()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="last_polled_at" label="最后轮询" width="180">
|
<el-table-column prop="last_polled_at" label="最后轮询" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.last_polled_at ? formatDate(scope.row.last_polled_at) : '-' }}
|
{{ scope.row.last_polled_at ? formatDateTime(scope.row.last_polled_at) : '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="300" align="center" fixed="right">
|
<el-table-column label="操作" width="300" align="center" fixed="right">
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<el-table-column prop="retries" label="重试次数" width="100" align="center" />
|
<el-table-column prop="retries" label="重试次数" width="100" align="center" />
|
||||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDate(scope.row.created_at) }}
|
{{ formatDateTime(scope.row.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
<el-table-column prop="interface" label="接口" width="120" />
|
<el-table-column prop="interface" label="接口" width="120" />
|
||||||
<el-table-column prop="last_seen" label="最后发现" width="180">
|
<el-table-column prop="last_seen" label="最后发现" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ formatDate(scope.row.last_seen) }}
|
{{ formatDateTime(scope.row.last_seen) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -275,6 +275,7 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { snmpApi } from '@/api'
|
import { snmpApi } from '@/api'
|
||||||
|
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
|
||||||
|
|
||||||
const activeTab = ref('devices')
|
const activeTab = ref('devices')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -397,7 +398,13 @@ const saveDevice = async () => {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (isDeviceEdit.value) {
|
if (isDeviceEdit.value) {
|
||||||
await snmpApi.updateDevice(deviceForm.id, deviceForm)
|
// 处理清除凭据:snmp_credential_id=null 时发送 clear_snmp_credential=true
|
||||||
|
const updateData = { ...deviceForm }
|
||||||
|
if (updateData.snmp_credential_id === null) {
|
||||||
|
delete updateData.snmp_credential_id
|
||||||
|
updateData.clear_snmp_credential = true
|
||||||
|
}
|
||||||
|
await snmpApi.updateDevice(deviceForm.id, updateData)
|
||||||
ElMessage.success('更新成功')
|
ElMessage.success('更新成功')
|
||||||
} else {
|
} else {
|
||||||
await snmpApi.createDevice(deviceForm)
|
await snmpApi.createDevice(deviceForm)
|
||||||
@@ -530,10 +537,7 @@ const deleteCredential = async (id) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date) => {
|
// 旧 formatDate 已由 @/utils/datetime 统一替代
|
||||||
if (!date) return '-'
|
|
||||||
return new Date(date).toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="最后登录" width="170">
|
<el-table-column label="最后登录" width="170">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
{{ scope.row.last_login_at ? formatDate(scope.row.last_login_at) : '-' }}
|
{{ scope.row.last_login_at ? formatDateTime(scope.row.last_login_at) : '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="280" align="center" fixed="right">
|
<el-table-column label="操作" width="280" align="center" fixed="right">
|
||||||
@@ -162,6 +162,7 @@ import { ref, reactive, onMounted, computed } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
|
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
|
||||||
import { userApi } from '@/api/auth'
|
import { userApi } from '@/api/auth'
|
||||||
|
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
|
||||||
import { authStore } from '@/utils/auth'
|
import { authStore } from '@/utils/auth'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -206,15 +207,7 @@ function roleTagType(r) { return ROLE_MAP[r]?.tagType || '' }
|
|||||||
function statusLabel(s) { return STATUS_MAP[s]?.label || s || '-' }
|
function statusLabel(s) { return STATUS_MAP[s]?.label || s || '-' }
|
||||||
function statusTagType(s) { return STATUS_MAP[s]?.tagType || '' }
|
function statusTagType(s) { return STATUS_MAP[s]?.tagType || '' }
|
||||||
|
|
||||||
function formatDate(d) {
|
// 旧 formatDate 已由 @/utils/datetime 统一替代
|
||||||
if (!d) return '-'
|
|
||||||
try {
|
|
||||||
const dt = new Date(d)
|
|
||||||
if (isNaN(dt.getTime())) return d
|
|
||||||
const pad = n => String(n).padStart(2, '0')
|
|
||||||
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
|
|
||||||
} catch { return d }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
@@ -138,15 +138,41 @@ start_frontend() {
|
|||||||
|
|
||||||
cd $FRONTEND_DIR
|
cd $FRONTEND_DIR
|
||||||
|
|
||||||
# 检查 node_modules
|
# 检查 node_modules 及关键依赖是否完整
|
||||||
if [ ! -d "node_modules" ]; then
|
if [ ! -d "node_modules" ] || [ ! -f "node_modules/element-plus/es/index.mjs" ]; then
|
||||||
echo -e "${YELLOW}安装前端依赖...${NC}"
|
echo -e "${YELLOW}安装前端依赖...${NC}"
|
||||||
npm install
|
npm install
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 检查 OUI 数据库文件
|
||||||
|
if [ ! -f "$BACKEND_DIR/app/data/oui.txt" ]; then
|
||||||
|
echo -e "${YELLOW}OUI 数据库不存在,尝试从 IEEE 下载...${NC}"
|
||||||
|
cd $BACKEND_DIR
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 -c "
|
||||||
|
from mac_vendor_lookup import MacLookup
|
||||||
|
import os
|
||||||
|
os.makedirs('app/data', exist_ok=True)
|
||||||
|
lookup = MacLookup()
|
||||||
|
lookup.update_vendors()
|
||||||
|
# 复制到项目目录
|
||||||
|
import shutil
|
||||||
|
src = os.path.expanduser('~/.cache/mac-vendors.txt')
|
||||||
|
if os.path.exists(src):
|
||||||
|
shutil.copy(src, 'app/data/oui.txt')
|
||||||
|
print('OUI 数据库下载完成')
|
||||||
|
" 2>/dev/null || echo -e "${YELLOW}OUI 数据库下载失败(网络问题),使用内置硬编码回退${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
# 检查端口并清理
|
# 检查端口并清理
|
||||||
kill_port $FRONTEND_PORT
|
kill_port $FRONTEND_PORT
|
||||||
|
|
||||||
|
# 清理 Vite 缓存,避免 element-plus exports 解析失败
|
||||||
|
if [ -d "node_modules/.vite" ]; then
|
||||||
|
echo -e "${YELLOW}清理 Vite 缓存...${NC}"
|
||||||
|
rm -rf node_modules/.vite
|
||||||
|
fi
|
||||||
|
|
||||||
# 后台启动前端
|
# 后台启动前端
|
||||||
nohup npm run dev -- --host 0.0.0.0 --port $FRONTEND_PORT > /tmp/ipam-frontend.log 2>&1 &
|
nohup npm run dev -- --host 0.0.0.0 --port $FRONTEND_PORT > /tmp/ipam-frontend.log 2>&1 &
|
||||||
FRONTEND_PID=$!
|
FRONTEND_PID=$!
|
||||||
@@ -159,9 +185,24 @@ start_frontend() {
|
|||||||
echo " 日志: /tmp/ipam-frontend.log"
|
echo " 日志: /tmp/ipam-frontend.log"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
echo -e "${RED}❌ 前端服务启动失败${NC}"
|
# 如果启动失败,尝试清缓存重试一次
|
||||||
echo " 查看日志: tail -50 /tmp/ipam-frontend.log"
|
echo -e "${YELLOW}首次启动失败,尝试清理 Vite 缓存重试...${NC}"
|
||||||
return 1
|
kill -9 $FRONTEND_PID 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
rm -rf node_modules/.vite
|
||||||
|
nohup npm run dev -- --host 0.0.0.0 --port $FRONTEND_PORT > /tmp/ipam-frontend.log 2>&1 &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
sleep 12
|
||||||
|
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
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user