7 Commits

Author SHA1 Message Date
Your Name 365ab73e7d feat(alerts): 飞书通知支持 Web 界面配置(数据库存储+热更新)
1. 新增 notification_config 模型,飞书配置存数据库(单行id=1)
2. feishu_service 改为从数据库读配置,支持热更新,Web保存即生效
3. alerts.py 新增接口:
   - GET /alerts/notify/feishu/config 读配置(secret只返回是否已设)
   - PUT /alerts/notify/feishu/config 保存配置+热更新
   (status/test 同步改为走数据库配置)
4. 前端告警中心增加「飞书通知设置」弹窗:
   - 开关/Webhook/签名密钥/最低级别/类型白名单
   - 保存配置、发送测试按钮
5. alert_service.create_alert 推送时按数据库配置判断

配置无需改文件/重启,前端界面直接保存即生效。
2026-08-12 13:14:38 +08:00
Your Name 717fa31456 feat(alerts): 告警中心增加飞书机器人告警推送功能
1. 新增 feishu_service.py: 飞书自定义机器人 webhook 推送
   - 支持卡片消息(interactive),按告警级别着色
   - 支持签名校验(HMAC-SHA256)
   - 支持最低级别过滤 + 告警类型白名单
2. alert_service.create_alert 创建新告警时自动推飞书
   - 仅新告警推送,去重告警不刷屏
   - 推送失败不影响告警创建
3. alerts.py 新增两个接口:
   - GET /alerts/notify/feishu/status 查看配置状态
   - POST /alerts/notify/feishu/test 发送测试告警
4. config.py + .env.example 增加 FEISHU_* 配置项
   (WEBHOOK_URL / SECRET / ENABLED / MIN_SEVERITY / ALERT_TYPES)
2026-08-12 13:10:08 +08:00
Your Name 3fd6ba3baf docs(readme): 更新部署文档为目录自适应 + 新增生产服务器更新流程
1. 生产部署建议改为推荐 start.sh 自动化方式(目录自适应、
   systemd 自动生成与开机自启、崩溃自动重启)。
2. 新增「生产服务器更新流程」:git pull 拉到最新代码后
   systemctl restart 三个服务即可,附完整验证命令。
3. 移除旧的手动创建 systemd 单元的过时文档(硬编码 /root/ipam)。
4. 统一把文档里的 /root/ipam 硬编码改为 <项目目录> 中性占位符。
2026-08-12 11:08:55 +08:00
Your Name 18cefa6ded feat(deploy): 目录自适应 + Celery 自动轮询接入 systemd
1. start.sh BASE_DIR 不再硬编码 /root/ipam,改为跟随脚本所在目录
   (BASH_SOURCE 自动检测),无论部署在 /opt /usr/local /root 等
   任意目录都能工作。
2. 运行 start.sh 时自动生成 3 个 systemd 单元并 enable 开机自启:
   - ipam-backend (uvicorn :8008)
   - ipam-celery-worker
   - ipam-celery-beat (每60秒触发SNMP设备自动轮询)
   均含 Restart=always 崩溃自动重启,日志写入 /var/log/ipam/。
3. 前端 vite dev server 保持 nohup 启动(不纳入 systemd)。
4. stop.sh 改为 systemctl stop 停止 systemd 服务,pkill 兜底。
5. .gitignore 忽略 celerybeat-schedule 运行期文件。
2026-08-12 11:05:18 +08:00
Your Name 5369d8d6e5 fix(alerts): 修复检测IP冲突时 db.func 误用导致立即检测500
detect_ip_conflicts 误用 db.func.count/db.func.distinct,而 func 是
SQLAlchemy 模块属性而非 Session 方法,触发 AttributeError 使
/alerts/detect/run 返回500(前端弹'请求失败')。

改为 from sqlalchemy import func 后使用 func.count/func.distinct
2026-08-12 11:01:03 +08:00
Your Name 8775c9836d feat(snmp): 实现网络设备自动轮询定时任务
之前只有 arp/mac/interface_poll_interval 字段但没有实际调度,
SNMP 设备添加后永远不会被自动采集。

新增 poll_snmp_devices Celery 定时任务:
- 遍历所有活跃且配置了 SNMP 凭据的设备
- 按各设备的 poll_interval 判断是否到期,到期才轮询
- 用 asyncio.run 执行异步的 SNMPService.poll_device
- Beat 每 60 秒触发检查,新设备 last_polled_at 为空会立即轮询
2026-08-12 10:56:40 +08:00
Your Name b7551af688 fix(scan): 修复单IP扫描500错误并清除MySQL不兼容SQL
1. enhanced_scan.py: comprehensive_scan_ip 误用 schema 的 IPAddress 做
   db.query 导致 ArgumentError(500)。改用 ORM 模型 IPAddressModel,与
   文件内其他函数一致。
2. enhanced_scan_service.py: update_ip_from_scan_result 使用 PostgreSQL
   的 @> 包含操作符,MySQL 上报 SQL语法错误(1064)。改为纯 Python
   ipaddress 遍历匹配网段,新增 _find_network_for_ip 辅助方法。
2026-08-12 10:47:32 +08:00
17 changed files with 976 additions and 175 deletions
+4
View File
@@ -22,6 +22,10 @@ dist/
*.log *.log
/tmp/ /tmp/
# Celery Beat 调度状态文件(运行期自动生成)
celerybeat-schedule*
celerybeat.pid
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+113 -78
View File
@@ -53,46 +53,55 @@
### 一键启动所有服务 ### 一键启动所有服务
#### 方式一:使用启动脚本(推荐) #### 方式一:使用启动脚本(推荐)
`start.sh` 目录自适应,无论部署在 `/opt``/usr/local``/root` 还是其它任意目录,
直接以脚本所在目录为项目根运行即可(需 root 权限):
```bash ```bash
cd /root/ipam cd /你的部署目录 # 例如 /opt/ipam,跟随你实际的部署路径
./start.sh bash start.sh
``` ```
运行时会自动:创建 Python venv → 安装后端依赖 → 启动 MySQL/Redis 容器 →
生成 3 个 systemd 服务(backend / celery-worker / celery-beat)并 enable 开机自启 →
启动前端。更多细节见下文「生产环境部署建议」。
#### 方式二:手动启动 #### 方式二:手动启动
> ⚠️ 仅供开发调试用。生产环境推荐使用方式一(start.sh),可自动生成 systemd 服务、开机自启、崩溃自动重启。
#### 安装依赖环境 #### 安装依赖环境
```bash ```bash
cd /root/ipam/backend # 以下 <项目目录> 均指你实际的部署路径,例如 /opt/ipam
cd <项目目录>/backend
source venv/bin/activate source venv/bin/activate
pip install "pysnmp==4.4.12" "pyasn1<0.5.0" "pysmi<0.4.0" pip install "pysnmp==4.4.12" "pyasn1<0.5.0" "pysmi<0.4.0"
apt install -y celery
``` ```
##### 1️⃣ 启动后端 API 服务 ##### 1️⃣ 启动后端 API 服务
```bash ```bash
cd /root/ipam/backend cd <项目目录>/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
``` ```
##### 2️⃣ 启动 Celery Worker(执行扫描任务) ##### 2️⃣ 启动 Celery Worker(执行扫描任务)
```bash ```bash
cd /root/ipam/backend cd <项目目录>/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
``` ```
##### 3️⃣ 启动 Celery Beat(定时任务调度器,自动扫描必须启动) ##### 3️⃣ 启动 Celery Beat(定时任务调度器,自动扫描必须启动)
```bash ```bash
cd /root/ipam/backend cd <项目目录>/backend
source venv/bin/activate source venv/bin/activate
celery -A app.tasks.celery_app beat --loglevel=info celery -A app.tasks.celery_app beat --loglevel=info
``` ```
##### 4️⃣ 启动前端 ##### 4️⃣ 启动前端
```bash ```bash
cd /root/ipam/frontend cd <项目目录>/frontend
npm install npm install
npm run dev -- --host 0.0.0.0 --port 3000 npm run dev -- --host 0.0.0.0 --port 3000
``` ```
@@ -113,7 +122,7 @@ npm run dev -- --host 0.0.0.0 --port 3000
### 如何自定义扫描频率 ### 如何自定义扫描频率
编辑配置文件:`/root/ipam/backend/app/tasks/celery_app.py` 编辑配置文件:`<项目目录>/backend/app/tasks/celery_app.py`
```python ```python
# 定时任务配置 (第 36-55 行) # 定时任务配置 (第 36-55 行)
@@ -169,7 +178,7 @@ celery_app.conf.beat_schedule = {
#### 查看 Celery 活跃任务 #### 查看 Celery 活跃任务
```bash ```bash
cd /root/ipam/backend cd <项目目录>/backend
source venv/bin/activate source venv/bin/activate
celery -A app.tasks.celery_app inspect active celery -A app.tasks.celery_app inspect active
``` ```
@@ -188,83 +197,109 @@ celery -A app.tasks.celery_app inspect stats
## 🛠️ 生产环境部署建议 ## 🛠️ 生产环境部署建议
### 使用 Systemd 管理服务(开机自启 ### 推荐方式:使用 start.sh(自动化,目录自适应
#### 1. 创建 IPAM API 服务 `start.sh` 已内置完整的生产级部署逻辑,**无需手动创建任何 systemd 单元**:
```ini
# /etc/systemd/system/ipam-api.service
[Unit]
Description=IPAM API Service
After=network.target mysql.service redis.service
[Service] - **目录自适应**:不硬编码路径,自动以脚本所在目录为项目根。无论部署在
Type=simple `/opt/ipam``/usr/local/ipam``/root/ipam` 还是其它任意目录都能工作。
User=root - **systemd 自动生成**:运行 `start.sh` 时自动生成并 `systemctl enable` 如下
WorkingDirectory=/root/ipam/backend 3 个服务,实现**开机自启** + **崩溃自动重启**`Restart=always`):
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 |--------|------|------|
``` | `ipam-backend` | uvicorn :8008 | 后端 API |
| `ipam-celery-worker` | celery worker | 执行扫描/轮询任务 |
| `ipam-celery-beat` | celery beat | 定时任务调度器(每60秒触发 SNMP 设备自动轮询) |
#### 2. 创建 Celery Worker 服务 日志写入 `/var/log/ipam/*.log`
```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 ```bash
# 重新加载配置 # 1. 前置:Python3 / Node.js / Docker 需已安装
systemctl daemon-reload # 2. git clone 代码(放到你想要的任何目录)
git clone https://git.cnbugs.com/AI-Agent/ipam.git /opt/ipam
cd /opt/ipam
# 启用开机自启 # 3. 一键启动(需 root 权限,自动装依赖 + 起 MySQL/Redis 容器 + 生成 systemd + 启前端)
systemctl enable ipam-api ipam-celery-worker ipam-celery-beat bash start.sh
# 启动服务
systemctl start ipam-api ipam-celery-worker ipam-celery-beat
# 查看服务状态
systemctl status ipam-api ipam-celery-worker ipam-celery-beat
``` ```
启动成功后即可访问:
- 管理界面 `http://服务器IP:3000`
- API 文档 `http://服务器IP:8008/docs`
> 💡 首次运行 `start.sh` 会自动创建 Python venv、安装前端依赖(含 vite)、
> 启动 MySQL/Redis Docker 容器、生成 3 个 systemd 服务并 enable 开机自启,
> 全程无需人工干预。若部署目录想换到别处,把项目整个复制过去再跑一次
> `bash start.sh` 即可自动重写 systemd 单元。
#### 服务管理命令
```bash
# 启动 / 停止 / 重启
bash start.sh
bash stop.sh
# 查看状态(开机自启 + 运行中)
systemctl status ipam-backend ipam-celery-worker ipam-celery-beat
# 查看日志
tail -f /var/log/ipam/ipam-backend.log
tail -f /var/log/ipam/ipam-celery-worker.log
tail -f /var/log/ipam/ipam-celery-beat.log
```
---
### 生产服务器更新流程(git 部署时)
生产代码是通过 `git clone` 放上去的,日常更新用 `git pull` 拉取远端最新代码,
再重启服务即可。**推荐更新流程**:
```bash
cd /你的部署目录 # 例如 /opt/ipam,跟随你实际的部署路径
# 1.(可选)备份当前脚本,防止意外
cp start.sh /tmp/start.sh.bak
# 2. 拉取远端最新代码(含全部修复:单IP扫描、SNMP自动轮询、告警检测、部署脚本)
git pull origin master
# 3. 重启服务让新代码生效(需 root)
# - 后端 / Celery Worker / Celery Beat 是 systemd 管理,重启它们即可
systemctl restart ipam-backend ipam-celery-worker ipam-celery-beat
# - 前端 vite dev server 是 nohup 方式,需手动重启:
pkill -f "vite" ; sleep 2
cd frontend && nohup npm run dev -- --host 0.0.0.0 --port 3000 > /tmp/ipam-frontend.log 2>&1 &
# 4. 全量一键启动(更省心:自动检测目录重新生成 systemd + 拉起全部服务)
# bash start.sh
# 5. 验证服务全部正常
systemctl is-active ipam-backend ipam-celery-worker ipam-celery-beat # 应输出 3 个 active
curl -s http://localhost:8008/health # 后端健康
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 # 前端 200
```
**更新要点:**
1. **后端 / Celery 是 systemd 管理**ipam-backend / ipam-celery-worker /
ipam-celery-beat),更新代码后只需 `systemctl restart` 这三个服务,
它们会保持开机自启和崩溃自动重启。
2. **前端是 nohup 方式**vite dev server,开发服务不纳入 systemd),需要
`pkill -f vite` 后重新 `npm run dev` 启动。
3. **依赖变更时**:若 `git pull` 拉取后要求新 Python 包,执行
`cd backend && venv/bin/pip install -r requirements.txt`
前端依赖变化则 `cd frontend && npm install --include=dev`
4. **若你切换了部署目录**(从 /opt 挪到 /usr/local 等),直接在新位置跑一次
`bash start.sh`,它会自动按新目录重新生成 systemd 单元,无需手动改任何配置。
5. **首次部署到新机器**建议直接跑 `bash start.sh`(一键全自动),日常增量更新
用第 2~4 步的重启方式即可,两者效果一致。
--- ---
## 🌐 访问地址 ## 🌐 访问地址
@@ -358,7 +393,7 @@ A: 1. 增加并发数 2. 关闭 DNS 解析(较慢)3. 分网段分时段扫
## 📁 项目结构 ## 📁 项目结构
``` ```
/root/ipam/ <项目目录>/ # 例如 /opt/ipam、/usr/local/ipam,任意目录均可
├── backend/ ├── backend/
│ ├── app/ │ ├── app/
│ │ ├── api/v1/ # API 路由 │ │ ├── api/v1/ # API 路由
+13
View File
@@ -17,3 +17,16 @@ SECRET_KEY=your-super-secret-key-here-change-in-production
PING_TIMEOUT=2 PING_TIMEOUT=2
PING_RETRIES=2 PING_RETRIES=2
SCAN_CONCURRENCY=50 SCAN_CONCURRENCY=50
# 飞书机器人告警通知配置
# 1. 在飞书群添加"自定义机器人",复制 Webhook 地址填入 FEISHU_WEBHOOK_URL
# 2. 若机器人开启了"签名校验",把密钥填 FEISHU_SECRET;未开启留空
# 3. FEISHU_ENABLED=true 开启总开关
# 4. FEISHU_MIN_SEVERITY=warning 仅推送 >= 该级别的告警(info/warning/error/critical
# 5. FEISHU_ALERT_TYPES 可选,逗号分隔的告警类型白名单,留空=全部推送
FEISHU_ENABLED=false
FEISHU_WEBHOOK_URL=
FEISHU_SECRET=
FEISHU_MIN_SEVERITY=warning
FEISHU_ALERT_TYPES=
+88
View File
@@ -181,3 +181,91 @@ def detect_offline_devices(db: Session = Depends(get_db)):
"count": len(offline_devices), "count": len(offline_devices),
"devices": offline_devices "devices": offline_devices
} }
# ========== 飞书告警通知 ==========
@router.get("/notify/feishu/config", summary="获取飞书通知配置")
def get_feishu_config(db: Session = Depends(get_db)):
"""读取飞书通知配置(Web 界面显示用,不返回 secret 原文只返回是否已设置)"""
from app.models.notification import NotificationConfig
row = NotificationConfig.get_singleton(db)
return {
"feishu_enabled": bool(row.feishu_enabled),
"feishu_webhook_url": row.feishu_webhook_url or "",
"feishu_secret_set": bool(row.feishu_secret),
"feishu_min_severity": row.feishu_min_severity or "warning",
"feishu_alert_types": row.feishu_alert_types or "",
}
@router.put("/notify/feishu/config", summary="保存飞书通知配置")
def update_feishu_config(
feishu_enabled: bool,
feishu_webhook_url: str = "",
feishu_secret: str = "",
feishu_min_severity: str = "warning",
feishu_alert_types: str = "",
db: Session = Depends(get_db),
):
"""保存飞书通知配置到数据库并热更新(无需重启后端)"""
from app.models.notification import NotificationConfig
row = NotificationConfig.get_singleton(db)
row.feishu_enabled = bool(feishu_enabled)
row.feishu_webhook_url = (feishu_webhook_url or "").strip()
# 仅当传入新 secret 时才覆盖(避免 UI 回显时清空已有 secret)
if feishu_secret:
row.feishu_secret = feishu_secret.strip()
row.feishu_min_severity = feishu_min_severity or "warning"
row.feishu_alert_types = (feishu_alert_types or "").strip()
db.commit()
# 热更新运行时的飞书服务
from app.services.feishu_service import reload_feishu_service
svc = reload_feishu_service(db)
return {
"message": "飞书通知配置已保存并生效",
"success": True,
"configured": svc.is_configured(),
}
@router.get("/notify/feishu/status", summary="获取飞书通知运行状态")
def get_feishu_status(db: Session = Depends(get_db)):
"""查看飞书通知运行状态"""
from app.services.feishu_service import get_feishu_service
svc = get_feishu_service(db)
return {
"enabled": svc.enabled,
"configured": bool(svc.webhook_url),
"webhook_set": bool(svc.webhook_url),
"secret_set": bool(svc.secret),
"min_severity": svc.min_severity,
"alert_types": svc.alert_types,
"cani_push": svc.is_configured(),
}
@router.post("/notify/feishu/test", summary="发送飞书测试告警")
def test_feishu_notification(db: Session = Depends(get_db)):
"""发送一条测试告警到飞书,验证 webhook 配置是否正确"""
from app.services.feishu_service import get_feishu_service
svc = get_feishu_service(db)
if not svc.is_configured():
raise HTTPException(
status_code=400,
detail="飞书通知未启用或未配置 webhook。请在告警中心「飞书通知设置」中配置并开启"
)
ok = svc.send_alert({
"alert_type": "new_device_detected",
"severity": "warning",
"title": "飞书通知测试",
"message": "这是一条测试告警,用于验证飞书机器人 webhook 配置是否正常。",
"ip_address_str": "172.16.0.1",
"mac_address": "00:11:22:33:44:55",
})
if ok:
return {"message": "飞书测试通知发送成功", "success": True}
raise HTTPException(status_code=500, detail="飞书测试通知发送失败,请检查 webhook 地址和日志")
+2 -1
View File
@@ -113,7 +113,8 @@ def comprehensive_scan_ip(
EnhancedScanService.update_ip_from_scan_result(db, result) EnhancedScanService.update_ip_from_scan_result(db, result)
# 刷新所属网段统计字段(used_ips/reserved_ips),保证网段列表利用率能即时反映 # 刷新所属网段统计字段(used_ips/reserved_ips),保证网段列表利用率能即时反映
db_ip = db.query(IPAddress).filter(IPAddress.ip_address == ip_address).first() from app.models.network import IPAddress as IPAddressModel
db_ip = db.query(IPAddressModel).filter(IPAddressModel.ip_address == ip_address).first()
if db_ip: if db_ip:
NetworkService.get_stats(db, db_ip.network_id) NetworkService.get_stats(db, db_ip.network_id)
+7
View File
@@ -28,6 +28,13 @@ class Settings(BaseSettings):
PING_RETRIES: int = 2 PING_RETRIES: int = 2
SCAN_CONCURRENCY: int = 50 SCAN_CONCURRENCY: int = 50
# 飞书机器人告警通知配置
FEISHU_ENABLED: bool = False # 总开关
FEISHU_WEBHOOK_URL: str = "" # 飞书自定义机器人 webhook 地址
FEISHU_SECRET: str = "" # 可选:机器人签名校验密钥(开启签名校验时必填)
FEISHU_MIN_SEVERITY: str = "warning" # 最低告警级别:info/warning/error/critical,仅推送 >= 该级别
FEISHU_ALERT_TYPES: str = "" # 可选:逗号分隔的告警类型白名单,空=全部推送
class Config: class Config:
env_file = ".env" env_file = ".env"
+1
View File
@@ -12,6 +12,7 @@ from app.models.snmp import SNMPCredential, NetworkDevice, ARPEntry, MACAddressT
from app.models.alert import Alert, AlertRule, WhitelistedMAC from app.models.alert import Alert, AlertRule, WhitelistedMAC
from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.notification import NotificationConfig
@asynccontextmanager @asynccontextmanager
+31
View File
@@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
from sqlalchemy.sql import func
from app.core.database import Base
class NotificationConfig(Base):
"""通知渠道配置(当前支持飞书机器人)。
使用单行记录(id=1)存储,便于 Web 界面读写和热更新。
"""
__tablename__ = "notification_config"
id = Column(Integer, primary_key=True)
# 飞书
feishu_enabled = Column(Boolean, default=False) # 总开关
feishu_webhook_url = Column(String(500), default="") # 机器人 webhook 地址
feishu_secret = Column(String(255), default="") # 签名校验密钥(可选)
feishu_min_severity = Column(String(20), default="warning") # 最低告警级别
feishu_alert_types = Column(Text, default="") # 逗号分隔的类型白名单,空=全部
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@classmethod
def get_singleton(cls, db):
"""获取或创建单行配置"""
row = db.query(cls).filter(cls.id == 1).first()
if row is None:
row = cls(id=1)
db.add(row)
db.commit()
db.refresh(row)
return row
+24 -1
View File
@@ -1,5 +1,6 @@
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import json import json
@@ -11,6 +12,12 @@ from app.models.alert import (
from app.models.network import Network, IPAddress from app.models.network import Network, IPAddress
from app.models.snmp import NetworkDevice, ARPEntry from app.models.snmp import NetworkDevice, ARPEntry
# 飞书告警通知(延迟导入避免循环依赖)
def _get_feishu_service(db=None):
from app.services.feishu_service import get_feishu_service
return get_feishu_service(db)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -69,6 +76,22 @@ class AlertService:
db.refresh(alert) db.refresh(alert)
logger.info(f"创建告警: {alert_type} - {title}") logger.info(f"创建告警: {alert_type} - {title}")
# 飞书告警通知(仅新创建的告警推送;existing 去重返回的不会走到这里,避免刷屏)
try:
_get_feishu_service(db).send_alert({
"alert_type": alert_type.value if hasattr(alert_type, "value") else str(alert_type),
"severity": severity.value if hasattr(severity, "value") else str(severity),
"title": title,
"message": message,
"ip_address_str": ip_address_str,
"mac_address": mac_address,
"conflicting_mac": conflicting_mac,
"usage_percent": usage_percent,
})
except Exception as e:
logger.error(f"飞书告警通知失败(不影响告警创建): {e}")
return alert return alert
@staticmethod @staticmethod
@@ -80,7 +103,7 @@ class AlertService:
subquery = db.query( subquery = db.query(
ARPEntry.ip_address ARPEntry.ip_address
).group_by(ARPEntry.ip_address).having( ).group_by(ARPEntry.ip_address).having(
db.func.count(db.func.distinct(ARPEntry.mac_address)) > 1 func.count(func.distinct(ARPEntry.mac_address)) > 1
).subquery() ).subquery()
conflict_entries = db.query(ARPEntry).filter( conflict_entries = db.query(ARPEntry).filter(
+26 -11
View File
@@ -429,17 +429,7 @@ class EnhancedScanService:
# 如果IP不存在,尝试自动创建 # 如果IP不存在,尝试自动创建
if not db_ip: if not db_ip:
network = db.query(Network).filter( network = EnhancedScanService._find_network_for_ip(db, ip_address)
Network.cidr.op('@>')((ip_address + '/32').encode('utf-8'))
).first()
if not network:
for net in db.query(Network).all():
try:
if ip_address in ipaddress.ip_network(net.cidr, strict=False):
network = net
break
except Exception:
pass
if not network: if not network:
logger.warning(f"无法为扫描到的IP {ip_address} 找到所属网段,跳过创建") logger.warning(f"无法为扫描到的IP {ip_address} 找到所属网段,跳过创建")
return return
@@ -481,6 +471,31 @@ class EnhancedScanService:
return db_ip return db_ip
@staticmethod
def _find_network_for_ip(db: Session, ip_address: str) -> Optional[Network]:
"""
查找 IP 所属的网段(兼容 MySQL,不使用 PostgreSQL 的 @> 操作符)。
优先按网段越大越精确匹配,无法精确匹配时返回包含该 IP 的最小/最合适网段。
"""
try:
target = ipaddress.ip_address(ip_address)
except ValueError:
return None
best = None
best_prefix = -1
for net in db.query(Network).all():
try:
cidr = str(net.cidr)
net_obj = ipaddress.ip_network(cidr, strict=False)
if target in net_obj:
# 取前缀最长的(网段最小、最精确)
if net_obj.prefixlen > best_prefix:
best = net
best_prefix = net_obj.prefixlen
except Exception:
continue
return best
@staticmethod @staticmethod
def bulk_update_ips_from_scan(db: Session, scan_results: List[Dict[str, Any]]): def bulk_update_ips_from_scan(db: Session, scan_results: List[Dict[str, Any]]):
"""批量更新IP信息""" """批量更新IP信息"""
+284
View File
@@ -0,0 +1,284 @@
"""
飞书机器人告警通知服务
========================
通过飞书自定义机器人 Webhook 推送告警通知,支持:
- 卡片消息(interactive
- 可选签名校验(加签模式)
- 按告警级别 / 类型过滤
- 同步发送(带超时),失败自动记录日志
"""
from typing import Optional, Dict, Any, List
from datetime import datetime
import base64
import hashlib
import hmac
import json
import logging
import time
import httpx
logger = logging.getLogger(__name__)
# 告警级别 → 颜色(飞书卡片 title 颜色)
SEVERITY_COLORS = {
"critical": "red",
"error": "red",
"warning": "orange",
"info": "blue",
}
# 告警级别 → 中文
SEVERITY_TEXTS = {
"critical": "严重",
"error": "错误",
"warning": "警告",
"info": "信息",
}
# 告警类型 → 中文
ALERT_TYPE_TEXTS = {
"ip_conflict": "IP 地址冲突",
"unauthorized_access": "未授权设备接入",
"subnet_full": "网段容量不足",
"scan_failed": "扫描失败",
"device_offline": "设备离线",
"new_device_detected": "新设备发现",
"mac_changed": "MAC 地址变更",
}
# 告警级别优先级(用于 >= 最低级别过滤)
_SEVERITY_ORDER = {"info": 0, "warning": 1, "error": 2, "critical": 3}
def _apply_sign(timestamp: str, secret: str) -> str:
"""飞书机器人签名校验:HMAC-SHA256(secret, timestamp + "\n" + secret),结果 base64"""
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
return base64.b64encode(hmac_code).decode("utf-8")
def build_card(alert_data: Dict[str, Any]) -> str:
"""把告警数据构造成飞书卡片消息体(interactive)。"""
severity = str(alert_data.get("severity") or "warning").lower()
alert_type = str(alert_data.get("alert_type") or "unknown").lower()
color = SEVERITY_COLORS.get(severity, "blue")
severity_text = SEVERITY_TEXTS.get(severity, severity)
alert_type_text = ALERT_TYPE_TEXTS.get(alert_type, alert_type)
title = alert_data.get("title") or "IPAM 告警"
message = alert_data.get("message") or ""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 组装字段(element),过滤空字段
fields = []
def add_field(tag_text, content_text):
if content_text:
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**{tag_text}** {content_text}"}})
add_field("告警级别", f"<font color='{color}'><b>{severity_text}</b></font>")
add_field("告警类型", alert_type_text)
add_field("详细信息", message)
add_field("相关 IP", alert_data.get("ip_address_str"))
add_field("相关 MAC", alert_data.get("mac_address"))
add_field("冲突 MAC", alert_data.get("conflicting_mac"))
if alert_data.get("usage_percent") is not None:
add_field("网段使用率", f"{alert_data['usage_percent']}%")
add_field("告警时间", now)
card = {
"msg_type": "interactive",
"card": {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": f"⚠️ IPAM 告警 - {title}"},
"template": color,
},
"elements": [
{"tag": "div", "fields": fields},
{
"tag": "note",
"elements": [
{"tag": "plain_text", "content": f"本消息由 IPAM 地址管理系统自动发送 · {now}"}
],
},
],
},
}
return json.dumps(card, ensure_ascii=False)
class FeishuService:
"""飞书机器人告警推送服务"""
def __init__(
self,
webhook_url: str = "",
secret: str = "",
enabled: bool = True,
min_severity: str = "warning",
alert_types: Optional[List[str]] = None,
):
self.webhook_url = webhook_url
self.secret = secret
self.enabled = enabled
self.min_severity = min_severity
self.alert_types = alert_types or []
self._client = httpx.Client(timeout=8.0)
def is_configured(self) -> bool:
"""是否已配置 webhook 地址且开关打开"""
return bool(self.enabled and self.webhook_url)
def should_push(self, severity: str, alert_type: str = "") -> bool:
"""按最低级别 + 类型白名单判断是否应推送"""
if not self.is_configured():
return False
# 级别过滤
sev = (severity or "warning").lower()
if _SEVERITY_ORDER.get(sev, 1) < _SEVERITY_ORDER.get(self.min_severity, 1):
return False
# 类型白名单(空 = 全部推送)
if self.alert_types and alert_type and alert_type not in self.alert_types:
return False
return True
def _build_headers(self) -> Dict[str, str]:
"""构造签名头(若配置了 secret"""
headers = {"Content-Type": "application/json; charset=utf-8"}
if self.secret:
timestamp = str(round(time.time()))
sign = _apply_sign(timestamp, self.secret)
headers["X-Lark-Sign"] = sign
headers["X-Lark-Timestamp"] = timestamp
return headers
def send_card(self, card_payload: str) -> bool:
"""发送飞书消息(card_payload 为 build_card 构造的 JSON 字符串)"""
if not self.is_configured():
logger.warning("飞书通知未启用或未配置 webhook,已跳过")
return False
try:
headers = self._build_headers()
resp = self._client.post(self.webhook_url, content=card_payload.encode("utf-8"), headers=headers)
try:
data = resp.json()
except Exception:
data = {"msg": resp.text}
# 飞书返回 code=0 表示成功
if resp.status_code == 200 and data.get("code") == 0:
logger.info(f"飞书通知发送成功: {data.get('msg', 'ok')}")
return True
logger.error(f"飞书通知发送失败: HTTP {resp.status_code} body={data}")
return False
except Exception as e:
logger.error(f"飞书通知发送异常: {str(e)}", exc_info=True)
return False
def send_alert(self, alert_data: Dict[str, Any]) -> bool:
"""发送一条告警通知(自动判断是否应推送)"""
severity = str(alert_data.get("severity") or "warning").lower()
alert_type = str(alert_data.get("alert_type") or "unknown").lower()
if not self.should_push(severity, alert_type):
logger.debug(f"告警 {severity}/{alert_type} 低于推送门槛或不在白名单,跳过")
return False
card = build_card(alert_data)
return self.send_card(card)
def close(self):
if self._client:
self._client.close()
# 全局单例
_service: Optional[FeishuService] = None
def _build_from_values(webhook_url="", secret="", enabled=False,
min_severity="warning", alert_types_str="") -> FeishuService:
"""用一组值构造 FeishuServiceapi 层用)"""
if _service is not None:
# 热更新:复用同一实例,更新内部配置
_service.webhook_url = webhook_url
_service.secret = secret
_service.enabled = bool(enabled)
_service.min_severity = min_severity or "warning"
_service.alert_types = [t.strip() for t in (alert_types_str or "").split(",") if t.strip()]
return _service
return FeishuService(
webhook_url=webhook_url,
secret=secret,
enabled=bool(enabled),
min_severity=min_severity or "warning",
alert_types=[t.strip() for t in (alert_types_str or "").split(",") if t.strip()],
)
def get_feishu_service(db=None) -> FeishuService:
"""获取(缓存的)飞书服务实例。
优先从数据库 notification_config 读取配置(Web 界面可热更新);
若无数据库配置则回退到 .env / settings 默认值。
"""
global _service
# 优先从数据库加载(Web 配置优先)
try:
if db is not None:
from app.models.notification import NotificationConfig
row = db.query(NotificationConfig).filter(NotificationConfig.id == 1).first()
if row is not None:
return _build_from_values(
webhook_url=row.feishu_webhook_url or "",
secret=row.feishu_secret or "",
enabled=row.feishu_enabled or False,
min_severity=row.feishu_min_severity or "warning",
alert_types_str=row.feishu_alert_types or "",
)
except Exception as e:
logger.warning(f"从数据库读取飞书配置失败,回退 .env: {e}")
# 回退到 settings.env
if _service is None:
try:
from app.core.config import settings
_service = _build_from_values(
webhook_url=settings.FEISHU_WEBHOOK_URL,
secret=settings.FEISHU_SECRET,
enabled=settings.FEISHU_ENABLED,
min_severity=settings.FEISHU_MIN_SEVERITY or "warning",
alert_types_str=settings.FEISHU_ALERT_TYPES or "",
)
except Exception as e:
logger.error(f"初始化飞书服务失败: {e}")
_service = FeishuService()
return _service
def reload_feishu_service(db) -> FeishuService:
"""强制从数据库重新加载飞书配置(Web 界面保存配置后调用)"""
global _service
from app.models.notification import NotificationConfig
row = db.query(NotificationConfig).filter(NotificationConfig.id == 1).first()
if row is None:
row = NotificationConfig.get_singleton(db)
svc = _build_from_values(
webhook_url=row.feishu_webhook_url or "",
secret=row.feishu_secret or "",
enabled=row.feishu_enabled or False,
min_severity=row.feishu_min_severity or "warning",
alert_types_str=row.feishu_alert_types or "",
)
global _service
_service = svc
return svc
def send_alert_notification(alert_data: Dict[str, Any], db=None) -> bool:
"""便捷函数:发送一条告警通知"""
return get_feishu_service(db).send_alert(alert_data)
+6
View File
@@ -32,6 +32,7 @@ scan_single_ip_task = _tasks['scan_single_ip_task']
full_network_scan = _tasks['full_network_scan'] full_network_scan = _tasks['full_network_scan']
update_all_statistics = _tasks['update_all_statistics'] update_all_statistics = _tasks['update_all_statistics']
quick_status_update = _tasks['quick_status_update'] quick_status_update = _tasks['quick_status_update']
poll_snmp_devices = _tasks['poll_snmp_devices']
# 定时任务配置 # 定时任务配置
celery_app.conf.beat_schedule = { celery_app.conf.beat_schedule = {
@@ -52,4 +53,9 @@ celery_app.conf.beat_schedule = {
'task': 'app.tasks.scan_tasks.update_all_statistics', 'task': 'app.tasks.scan_tasks.update_all_statistics',
'schedule': 900.0, # 15分钟 'schedule': 900.0, # 15分钟
}, },
# 每60秒检查一次 SNMP 设备轮询(各设备按自己的 poll_interval 判断是否到期)
'poll-snmp-devices-every-60s': {
'task': 'app.tasks.scan_tasks.poll_snmp_devices',
'schedule': 60.0,
},
} }
+65 -1
View File
@@ -212,11 +212,75 @@ def register_tasks(celery_app):
finally: finally:
db.close() db.close()
@celery_app.task(bind=True)
def poll_snmp_devices(self):
"""
定时任务:自动轮询所有活跃且配置了 SNMP 凭据的网络设备。
每个设备按自己的 arp/mac/interface_poll_interval(秒)判断是否到达
轮询间隔,到期才执行 SNMPService.poll_device(采集 ARP 表、MAC 地址
表、接口信息)。由 Celery Beat 每 60 秒触发一次检查。
"""
import asyncio
from datetime import datetime
from app.models.snmp import NetworkDevice
from app.services.snmp_service import SNMPService
db = SessionLocal()
try:
devices = db.query(NetworkDevice).filter(
NetworkDevice.is_active == True,
NetworkDevice.snmp_credential_id.isnot(None),
).all()
now = datetime.utcnow()
def _as_naive(dt):
"""把 aware/naive datetime 统一转 naive(本地比较用)"""
if dt is None:
return None
if dt.tzinfo is not None:
return dt.replace(tzinfo=None)
return dt
polled, skipped = 0, 0
for dev in devices:
# 取三种采集间隔的最小值作为该设备的轮询周期
intervals = [
dev.arp_poll_interval,
dev.mac_poll_interval,
dev.interface_poll_interval,
]
interval = min([i for i in intervals if i], default=300)
last = _as_naive(dev.last_polled_at)
if last is not None:
elapsed = (now - last).total_seconds()
if elapsed < interval:
skipped += 1
continue
try:
# poll_device 是 async 函数,用 asyncio.run 在同步任务中执行
asyncio.run(SNMPService.poll_device(db, dev.id))
polled += 1
logger.info(f"SNMP 自动轮询完成: {dev.name} ({dev.ip_address})")
except Exception as e:
logger.error(f"SNMP 自动轮询失败 {dev.name} ({dev.ip_address}): {e}", exc_info=True)
logger.info(f"SNMP 自动轮询检查完成: 设备总数 {len(devices)}, 本轮轮询 {polled}, 未到期跳过 {skipped}")
return {'status': 'completed', 'total': len(devices), 'polled': polled, 'skipped': skipped}
finally:
db.close()
# 返回任务函数供外部使用 # 返回任务函数供外部使用
return { return {
'scan_network_task': scan_network_task, 'scan_network_task': scan_network_task,
'scan_single_ip_task': scan_single_ip_task, 'scan_single_ip_task': scan_single_ip_task,
'full_network_scan': full_network_scan, 'full_network_scan': full_network_scan,
'update_all_statistics': update_all_statistics, 'update_all_statistics': update_all_statistics,
'quick_status_update': quick_status_update 'quick_status_update': quick_status_update,
'poll_snmp_devices': poll_snmp_devices,
} }
+13
View File
@@ -131,6 +131,19 @@ export const alertApi = {
}, },
removeFromWhitelist(id) { removeFromWhitelist(id) {
return api.delete(`/alerts/whitelist/macs/${id}`) return api.delete(`/alerts/whitelist/macs/${id}`)
},
// 飞书通知配置
getFeishuConfig() {
return api.get('/alerts/notify/feishu/config')
},
saveFeishuConfig(data) {
return api.put('/alerts/notify/feishu/config', {}, { params: data })
},
getFeishuStatus() {
return api.get('/alerts/notify/feishu/status')
},
testFeishu() {
return api.post('/alerts/notify/feishu/test')
} }
} }
+106
View File
@@ -13,6 +13,10 @@
<el-icon><Check /></el-icon> <el-icon><Check /></el-icon>
全部确认 全部确认
</el-button> </el-button>
<el-button @click="openFeishuDialog">
<el-icon><ChatDotRound /></el-icon>
飞书通知设置
</el-button>
</el-button-group> </el-button-group>
</div> </div>
</template> </template>
@@ -231,6 +235,47 @@
<el-button type="primary" @click="addToWhitelist" :loading="submitting">确定</el-button> <el-button type="primary" @click="addToWhitelist" :loading="submitting">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 飞书通知设置弹窗 -->
<el-dialog v-model="feishuDialogVisible" title="飞书通知设置" width="560px">
<el-alert type="info" :closable="false" style="margin-bottom: 16px"
title="在飞书群添加「自定义机器人」,将 Webhook 地址填入下方即可。开启后,检测到新告警会自动推送到飞书群。"
show-icon />
<el-form label-width="110px" label-position="left">
<el-form-item label="启用通知">
<el-switch v-model="feishuForm.feishu_enabled" active-text="开启" inactive-text="关闭" />
</el-form-item>
<el-form-item label="Webhook 地址">
<el-input v-model="feishuForm.feishu_webhook_url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxxx" />
</el-form-item>
<el-form-item label="签名密钥">
<el-input v-model="feishuForm.feishu_secret" show-password
:placeholder="feishuForm.feishu_secret_set ? '已设置(留空则不修改)' : '机器人开启签名校验时填写'" />
<div class="form-tip">机器人开启签名校验时必填未开启留空即可</div>
</el-form-item>
<el-form-item label="最低级别">
<el-select v-model="feishuForm.feishu_min_severity" style="width: 200px">
<el-option label="全部 (info起)" value="info" />
<el-option label="警告及以上" value="warning" />
<el-option label="错误及以上" value="error" />
<el-option label="仅严重" value="critical" />
</el-select>
<div class="form-tip">仅推送大于等于所选级别的告警</div>
</el-form-item>
<el-form-item label="类型白名单">
<el-input v-model="feishuForm.feishu_alert_types" placeholder="留空=全部推送;如 ip_conflict,device_offline" />
<div class="form-tip">逗号分隔告警类型留空则推送全部类型</div>
</el-form-item>
</el-form>
<div style="color: #909399; font-size: 12px; margin-top: 8px;">
配置保存后立即生效无需重启服务
</div>
<template #footer>
<el-button @click="feishuDialogVisible = false">关闭</el-button>
<el-button @click="testFeishu" :loading="testing">发送测试</el-button>
<el-button type="primary" @click="saveFeishuConfig" :loading="savingFeishu">保存配置</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -463,6 +508,67 @@ const getAlertTypeText = (type) => {
// 旧 formatDate 已由 @/utils/datetime 统一替代 // 旧 formatDate 已由 @/utils/datetime 统一替代
// ---- 飞书通知配置 ----
const feishuDialogVisible = ref(false)
const savingFeishu = ref(false)
const testing = ref(false)
const feishuForm = reactive({
feishu_enabled: false,
feishu_webhook_url: '',
feishu_secret: '',
feishu_secret_set: false,
feishu_min_severity: 'warning',
feishu_alert_types: ''
})
const openFeishuDialog = async () => {
feishuDialogVisible.value = true
try {
const cfg = await alertApi.getFeishuConfig()
feishuForm.feishu_enabled = !!cfg.feishu_enabled
feishuForm.feishu_webhook_url = cfg.feishu_webhook_url || ''
feishuForm.feishu_secret = ''
feishuForm.feishu_secret_set = !!cfg.feishu_secret_set
feishuForm.feishu_min_severity = cfg.feishu_min_severity || 'warning'
feishuForm.feishu_alert_types = cfg.feishu_alert_types || ''
} catch (error) {
ElMessage.error('加载飞书配置失败')
}
}
const saveFeishuConfig = async () => {
savingFeishu.value = true
try {
const params = {
feishu_enabled: feishuForm.feishu_enabled,
feishu_webhook_url: feishuForm.feishu_webhook_url,
feishu_secret: feishuForm.feishu_secret,
feishu_min_severity: feishuForm.feishu_min_severity,
feishu_alert_types: feishuForm.feishu_alert_types
}
const res = await alertApi.saveFeishuConfig(params)
ElMessage.success(res.message || '飞书配置已保存')
feishuForm.feishu_secret = ''
feishuForm.feishu_secret_set = true
} catch (error) {
ElMessage.error('保存飞书配置失败')
} finally {
savingFeishu.value = false
}
}
const testFeishu = async () => {
testing.value = true
try {
const res = await alertApi.testFeishu()
ElMessage.success(res.message || '测试通知已发送')
} catch (error) {
ElMessage.error(error?.response?.data?.detail || '发送测试失败,请检查配置')
} finally {
testing.value = false
}
}
onMounted(() => { onMounted(() => {
loadStats() loadStats()
loadAlerts() loadAlerts()
+156 -67
View File
@@ -1,5 +1,13 @@
#!/bin/bash #!/bin/bash
# IPAM 管理系统一键启动脚本 # IPAM 管理系统一键启动脚本
#
# 特点:
# - 目录自适应:不硬编码 /root/ipam,自动跟随本脚本所在目录。
# 无论部署在 /opt、/usr/local、/root 还是其它任意目录都能工作。
# - systemd 管理:后端、Celery Worker、Celery Beat 都生成 systemd 单元
# ipam-backend / ipam-celery-worker / ipam-celery-beat),
# 运行本脚本时自动生成并 enable,保证开机自启、崩溃自动重启。
# - 前端 vite dev server 仍用 nohup 启动(开发服务,不纳入 systemd)。
echo "==========================================" echo "=========================================="
echo " IPAM 管理系统启动脚本" echo " IPAM 管理系统启动脚本"
@@ -12,8 +20,10 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
# 项目根目录 # ============ 目录自适应(核心改动)============
BASE_DIR="/root/ipam" # 无论脚本放哪里,都以其所在目录为项目根目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_DIR="$SCRIPT_DIR"
BACKEND_DIR="$BASE_DIR/backend" BACKEND_DIR="$BASE_DIR/backend"
FRONTEND_DIR="$BASE_DIR/frontend" FRONTEND_DIR="$BASE_DIR/frontend"
@@ -21,20 +31,31 @@ FRONTEND_DIR="$BASE_DIR/frontend"
API_PORT=8008 API_PORT=8008
FRONTEND_PORT=3000 FRONTEND_PORT=3000
# systemd 服务名
SYSTEMD_BACKEND="ipam-backend"
SYSTEMD_CELERY_WORKER="ipam-celery-worker"
SYSTEMD_CELERY_BEAT="ipam-celery-beat"
# 日志目录
LOG_DIR="/var/log/ipam"
mkdir -p "$LOG_DIR" 2>/dev/null
echo -e "${GREEN}📁 项目目录: $BASE_DIR${NC}"
# 检查 Docker 容器状态 # 检查 Docker 容器状态
check_docker_containers() { check_docker_containers() {
echo "🔍 检查 Docker 容器..." echo "🔍 检查 Docker 容器..."
# 检查 MySQL # 检查 MySQL
if [ "$(docker inspect -f '{{.State.Running}}' ipam-mysql 2>/dev/null)" != "true" ]; then if [ "$(docker inspect -f '{{.State.Running}}' ipam-mysql 2>/dev/null)" != "true" ]; then
echo -e "${YELLOW}MySQL 容器未运行,正在启动...${NC}" echo -e "${YELLOW}MySQL 容器未运行,正在启动...${NC}"
docker start ipam-mysql 2>/dev/null || { docker start ipam-mysql 2>/dev/null || {
echo -e "${YELLOW}创建 MySQL 容器...${NC}" echo -e "${YELLOW}创建 MySQL 容器...${NC}"
docker run -d --name ipam-mysql -p 3308:3306 \ docker run -d --name ipam-mysql -p 3308:3306 \
-e MYSQL_ROOT_PASSWORD=*** \ -e MYSQL_ROOT_PASSWORD=ipam2024 \
-e MYSQL_DATABASE=ipam \ -e MYSQL_DATABASE=ipam \
-e MYSQL_USER=ipam \ -e MYSQL_USER=ipam \
-e MYSQL_PASSWORD=*** \ -e MYSQL_PASSWORD=ipam2024 \
mysql:8.0 --default-authentication-plugin=mysql_native_password mysql:8.0 --default-authentication-plugin=mysql_native_password
} }
fi fi
@@ -63,87 +84,151 @@ kill_port() {
fi fi
} }
# 启动后端 API # ============ systemd 单元动态生成 ============
# 生成 3 个服务单元(backend / celery-worker / celery-beat
# 路径全部跟随 BASE_DIR,保证部署目录变更后重新运行本脚本即可重写
setup_systemd_services() {
echo "⚙️ 生成 systemd 服务单元..."
# --- 后端 ---
cat > /etc/systemd/system/$SYSTEMD_BACKEND.service <<EOF
[Unit]
Description=IPAM Backend API (uvicorn)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=$BACKEND_DIR
Environment=PYTHONPATH=$BACKEND_DIR
ExecStart=$BACKEND_DIR/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port $API_PORT
Restart=always
RestartSec=3
StandardOutput=append:$LOG_DIR/ipam-backend.log
StandardError=append:$LOG_DIR/ipam-backend.log
[Install]
WantedBy=multi-user.target
EOF
# --- Celery Worker ---
cat > /etc/systemd/system/$SYSTEMD_CELERY_WORKER.service <<EOF
[Unit]
Description=IPAM Celery Worker
After=$SYSTEMD_BACKEND.service network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=$BACKEND_DIR
Environment=PYTHONPATH=$BACKEND_DIR
ExecStart=$BACKEND_DIR/venv/bin/celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
Restart=always
RestartSec=5
StandardOutput=append:$LOG_DIR/ipam-celery-worker.log
StandardError=append:$LOG_DIR/ipam-celery-worker.log
[Install]
WantedBy=multi-user.target
EOF
# --- Celery Beat ---
cat > /etc/systemd/system/$SYSTEMD_CELERY_BEAT.service <<EOF
[Unit]
Description=IPAM Celery Beat Scheduler
After=$SYSTEMD_CELERY_WORKER.service network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=$BACKEND_DIR
Environment=PYTHONPATH=$BACKEND_DIR
ExecStart=$BACKEND_DIR/venv/bin/celery -A app.tasks.celery_app beat --loglevel=info
Restart=always
RestartSec=5
StandardOutput=append:$LOG_DIR/ipam-celery-beat.log
StandardError=append:$LOG_DIR/ipam-celery-beat.log
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
# enable 开机自启
systemctl enable $SYSTEMD_BACKEND >/dev/null 2>&1
systemctl enable $SYSTEMD_CELERY_WORKER >/dev/null 2>&1
systemctl enable $SYSTEMD_CELERY_BEAT >/dev/null 2>&1
echo -e "${GREEN}✅ systemd 单元已生成并启用开机自启:${NC}"
echo " - $SYSTEMD_BACKEND.service"
echo " - $SYSTEMD_CELERY_WORKER.service"
echo " - $SYSTEMD_CELERY_BEAT.service"
echo ""
}
# 启动后端(systemd
start_backend() { start_backend() {
echo "🚀 启动后端 API 服务..." echo "🚀 启动后端 API 服务..."
# 检查虚拟环境 # 检查虚拟环境
if [ ! -d "$BACKEND_DIR/venv" ]; then if [ ! -d "$BACKEND_DIR/venv" ]; then
echo -e "${YELLOW}创建 Python 虚拟环境...${NC}" echo -e "${YELLOW}创建 Python 虚拟环境...${NC}"
cd $BACKEND_DIR cd $BACKEND_DIR
python3 -m venv venv python3 -m venv venv
fi fi
# 激活虚拟环境并启动 # 检查并清理占用端口的旧进程
cd $BACKEND_DIR
source venv/bin/activate
# 检查端口并清理
kill_port $API_PORT kill_port $API_PORT
# 后台启动 uvicorn # 通过 systemd 启动
nohup uvicorn app.main:app --host 0.0.0.0 --port $API_PORT > /tmp/ipam-backend.log 2>&1 & systemctl restart $SYSTEMD_BACKEND
BACKEND_PID=$! sleep 4
# 等待服务启动
sleep 5
# 检查是否启动成功
if curl -s http://localhost:$API_PORT/health > /dev/null; then if curl -s http://localhost:$API_PORT/health > /dev/null; then
echo -e "${GREEN}✅ 后端 API 启动成功 (端口 $API_PORT)${NC}" echo -e "${GREEN}✅ 后端 API 启动成功 (端口 $API_PORT, systemd 管理)${NC}"
echo " PID: $BACKEND_PID"
echo " 日志: /tmp/ipam-backend.log"
return 0 return 0
else else
echo -e "${RED}❌ 后端 API 启动失败${NC}" echo -e "${RED}❌ 后端 API 启动失败${NC}"
echo " 查看日志: tail -50 /tmp/ipam-backend.log" echo " 查看日志: journalctl -u $SYSTEMD_BACKEND -n 50 或 tail -50 $LOG_DIR/ipam-backend.log"
return 1 return 1
fi fi
echo "" echo ""
} }
# 启动 Celery Worker # 启动 Celerysystemdworker + beat
start_celery() { start_celery() {
echo "🚀 启动 Celery Worker..." echo "🚀 启动 Celery Worker & Beat (systemd)..."
systemctl restart $SYSTEMD_CELERY_WORKER
cd $BACKEND_DIR systemctl restart $SYSTEMD_CELERY_BEAT
source venv/bin/activate sleep 4
# 检查是否已有 celery 进程 systemctl is-active --quiet $SYSTEMD_CELERY_WORKER && WORKER_ACTIVE="yes" || WORKER_ACTIVE="no"
pkill -f "celery.*app.tasks.celery_app" 2>/dev/null systemctl is-active --quiet $SYSTEMD_CELERY_BEAT && BEAT_ACTIVE="yes" || BEAT_ACTIVE="no"
sleep 1
if [ "$WORKER_ACTIVE" = "yes" ] && [ "$BEAT_ACTIVE" = "yes" ]; then
# 后台启动 celery echo -e "${GREEN}✅ Celery Worker & Beat 启动成功 (systemd 管理)${NC}"
nohup celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4 > /tmp/ipam-celery.log 2>&1 &
CELERY_PID=$!
sleep 3
if ps -p $CELERY_PID > /dev/null; then
echo -e "${GREEN}✅ Celery Worker 启动成功${NC}"
echo " PID: $CELERY_PID"
echo " 日志: /tmp/ipam-celery.log"
return 0 return 0
else else
echo -e "${RED}❌ Celery Worker 启动失败${NC}" echo -e "${RED}❌ Celery 启动异常 (worker=$WORKER_ACTIVE beat=$BEAT_ACTIVE)${NC}"
echo " 查看日志: tail -50 /tmp/ipam-celery.log" echo " 查看日志: journalctl -u $SYSTEMD_CELERY_WORKER -n 30 -u $SYSTEMD_CELERY_BEAT -n 30"
return 1 return 1
fi fi
echo "" echo ""
} }
# 启动前端 # 启动前端nohup,开发服务)
start_frontend() { start_frontend() {
echo "🚀 启动前端服务..." echo "🚀 启动前端服务..."
cd $FRONTEND_DIR cd $FRONTEND_DIR
# 检查 node_modules 及关键依赖是否完整 # 检查 node_modules 及关键依赖是否完整(含 vite,全局 npm 可能配置了 omit=dev
if [ ! -d "node_modules" ] || [ ! -f "node_modules/element-plus/es/index.mjs" ]; then if [ ! -d "node_modules" ] || [ ! -f "node_modules/vite/bin/vite.js" ]; then
echo -e "${YELLOW}安装前端依赖...${NC}" echo -e "${YELLOW}安装前端依赖(含 devDependencies,保证 vite 可用)...${NC}"
npm install npm install --include=dev
fi fi
# 检查 OUI 数据库文件 # 检查 OUI 数据库文件
if [ ! -f "$BACKEND_DIR/app/data/oui.txt" ]; then if [ ! -f "$BACKEND_DIR/app/data/oui.txt" ]; then
echo -e "${YELLOW}OUI 数据库不存在,尝试从 IEEE 下载...${NC}" echo -e "${YELLOW}OUI 数据库不存在,尝试从 IEEE 下载...${NC}"
@@ -155,7 +240,6 @@ import os
os.makedirs('app/data', exist_ok=True) os.makedirs('app/data', exist_ok=True)
lookup = MacLookup() lookup = MacLookup()
lookup.update_vendors() lookup.update_vendors()
# 复制到项目目录
import shutil import shutil
src = os.path.expanduser('~/.cache/mac-vendors.txt') src = os.path.expanduser('~/.cache/mac-vendors.txt')
if os.path.exists(src): if os.path.exists(src):
@@ -163,29 +247,28 @@ if os.path.exists(src):
print('OUI 数据库下载完成') print('OUI 数据库下载完成')
" 2>/dev/null || echo -e "${YELLOW}OUI 数据库下载失败(网络问题),使用内置硬编码回退${NC}" " 2>/dev/null || echo -e "${YELLOW}OUI 数据库下载失败(网络问题),使用内置硬编码回退${NC}"
fi fi
# 检查端口并清理 # 检查端口并清理
kill_port $FRONTEND_PORT kill_port $FRONTEND_PORT
# 清理 Vite 缓存,避免 element-plus exports 解析失败 # 清理 Vite 缓存,避免 element-plus exports 解析失败
if [ -d "node_modules/.vite" ]; then if [ -d "node_modules/.vite" ]; then
echo -e "${YELLOW}清理 Vite 缓存...${NC}" echo -e "${YELLOW}清理 Vite 缓存...${NC}"
rm -rf node_modules/.vite rm -rf node_modules/.vite
fi 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=$!
sleep 8 sleep 8
if curl -s http://localhost:$FRONTEND_PORT > /dev/null; then if curl -s http://localhost:$FRONTEND_PORT > /dev/null; then
echo -e "${GREEN}✅ 前端服务启动成功 (端口 $FRONTEND_PORT)${NC}" echo -e "${GREEN}✅ 前端服务启动成功 (端口 $FRONTEND_PORT)${NC}"
echo " PID: $FRONTEND_PID" echo " PID: $FRONTEND_PID"
echo " 日志: /tmp/ipam-frontend.log" echo " 日志: /tmp/ipam-frontend.log"
return 0 return 0
else else
# 如果启动失败,尝试清缓存重试一次
echo -e "${YELLOW}首次启动失败,尝试清理 Vite 缓存重试...${NC}" echo -e "${YELLOW}首次启动失败,尝试清理 Vite 缓存重试...${NC}"
kill -9 $FRONTEND_PID 2>/dev/null kill -9 $FRONTEND_PID 2>/dev/null
sleep 1 sleep 1
@@ -217,8 +300,9 @@ show_status() {
echo "📊 服务状态:" echo "📊 服务状态:"
echo " - MySQL: 127.0.0.1:3308 ✅" echo " - MySQL: 127.0.0.1:3308 ✅"
echo " - Redis: 127.0.0.1:6379 ✅" echo " - Redis: 127.0.0.1:6379 ✅"
echo " - 后端 API: http://$(hostname -I | awk '{print $1}'):$API_PORT" echo " - 后端 API: http://$(hostname -I | awk '{print $1}'):$API_PORT (systemd: $SYSTEMD_BACKEND)"
echo " - Celery: 后台运行 ✅" echo " - Celery Worker: systemd: $SYSTEMD_CELERY_WORKER"
echo " - Celery Beat: systemd: $SYSTEMD_CELERY_BEAT ✅(每60秒触发SNMP设备自动轮询)"
echo " - 前端界面: http://$(hostname -I | awk '{print $1}'):$FRONTEND_PORT" echo " - 前端界面: http://$(hostname -I | awk '{print $1}'):$FRONTEND_PORT"
echo "" echo ""
echo "📖 访问地址:" echo "📖 访问地址:"
@@ -226,7 +310,11 @@ show_status() {
echo " - API 文档: http://$(hostname -I | awk '{print $1}'):$API_PORT/docs" echo " - API 文档: http://$(hostname -I | awk '{print $1}'):$API_PORT/docs"
echo "" echo ""
echo "🛑 停止服务命令:" echo "🛑 停止服务命令:"
echo " pkill -f 'uvicorn|celery|vite'" echo " bash $SCRIPT_DIR/stop.sh"
echo " 或 systemctl stop $SYSTEMD_CELERY_BEAT $SYSTEMD_CELERY_WORKER $SYSTEMD_BACKEND"
echo ""
echo "📈 查看状态:systemctl status $SYSTEMD_BACKEND $SYSTEMD_CELERY_WORKER $SYSTEMD_CELERY_BEAT"
echo "📈 查看日志:journalctl -u $SYSTEMD_CELERY_BEAT -f"
echo "" echo ""
} }
@@ -234,7 +322,7 @@ show_status() {
main() { main() {
# 检查是否是 root 用户 # 检查是否是 root 用户
if [ "$EUID" -ne 0 ]; then if [ "$EUID" -ne 0 ]; then
echo -e "${RED}请使用 root 权限运行此脚本${NC}" echo -e "${RED}请使用 root 权限运行此脚本(生成 systemd 单元需要)${NC}"
exit 1 exit 1
fi fi
@@ -258,6 +346,7 @@ main() {
echo "" echo ""
check_docker_containers check_docker_containers
setup_systemd_services
start_backend start_backend
start_celery start_celery
start_frontend start_frontend
+37 -16
View File
@@ -1,5 +1,8 @@
#!/bin/bash #!/bin/bash
# IPAM 管理系统停止脚本 # IPAM 管理系统停止脚本
#
# 通过 systemd 停止后端与 Celery(若服务已接入 systemd),
# 并停止前端 dev server。
echo "==========================================" echo "=========================================="
echo " IPAM 管理系统停止脚本" echo " IPAM 管理系统停止脚本"
@@ -12,37 +15,52 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
# 目录自适应(跟随本脚本所在目录)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_DIR="$SCRIPT_DIR"
# systemd 服务名(与 start.sh 保持一致)
SYSTEMD_BACKEND="ipam-backend"
SYSTEMD_CELERY_WORKER="ipam-celery-worker"
SYSTEMD_CELERY_BEAT="ipam-celery-beat"
echo "🛑 正在停止所有服务..." echo "🛑 正在停止所有服务..."
echo "" echo ""
# 停止后端 # 停止 Celery Beat
echo "停止后端 API 服务..." echo "停止 Celery Beat..."
pkill -f "uvicorn app.main:app" 2>/dev/null if systemctl stop $SYSTEMD_CELERY_BEAT 2>/dev/null; then
if [ $? -eq 0 ]; then echo -e "${GREEN}✅ Celery Beat 已停止 (systemd)${NC}"
echo -e "${GREEN}✅ 后端已停止${NC}"
else else
echo -e "${YELLOW}️ 后端未运行${NC}" pkill -f "celery.*app.tasks.celery_app.*beat" 2>/dev/null
echo -e "${YELLOW}️ Celery Beat 已停止 (pkill 兜底)${NC}"
fi fi
# 停止 Celery # 停止 Celery Worker
echo "" echo ""
echo "停止 Celery Worker..." echo "停止 Celery Worker..."
pkill -f "celery.*app.tasks.celery_app" 2>/dev/null if systemctl stop $SYSTEMD_CELERY_WORKER 2>/dev/null; then
if [ $? -eq 0 ]; then echo -e "${GREEN}✅ Celery Worker 已停止 (systemd)${NC}"
echo -e "${GREEN}✅ Celery Worker 已停止${NC}"
else else
echo -e "${YELLOW}️ Celery Worker 未运行${NC}" pkill -f "celery.*app.tasks.celery_app" 2>/dev/null
echo -e "${YELLOW}️ Celery Worker 已停止 (pkill 兜底)${NC}"
fi
# 停止后端
echo ""
echo "停止后端 API 服务..."
if systemctl stop $SYSTEMD_BACKEND 2>/dev/null; then
echo -e "${GREEN}✅ 后端已停止 (systemd)${NC}"
else
pkill -f "uvicorn app.main:app" 2>/dev/null
echo -e "${YELLOW}ℹ️ 后端已停止 (pkill 兜底)${NC}"
fi fi
# 停止前端 # 停止前端
echo "" echo ""
echo "停止前端服务..." echo "停止前端服务..."
pkill -f "vite" 2>/dev/null pkill -f "vite" 2>/dev/null
if [ $? -eq 0 ]; then echo -e "${GREEN}✅ 前端服务已停止${NC}"
echo -e "${GREEN}✅ 前端服务已停止${NC}"
else
echo -e "${YELLOW}️ 前端服务未运行${NC}"
fi
echo "" echo ""
echo "==========================================" echo "=========================================="
@@ -52,3 +70,6 @@ echo ""
echo "📋 剩余进程检查:" echo "📋 剩余进程检查:"
ps aux | grep -E "(uvicorn|celery|vite)" | grep -v grep | awk '{print $2, $11}' ps aux | grep -E "(uvicorn|celery|vite)" | grep -v grep | awk '{print $2, $11}'
echo "" echo ""
echo "💡 若希望永久停止开机自启,可执行:"
echo " systemctl disable $SYSTEMD_BACKEND $SYSTEMD_CELERY_WORKER $SYSTEMD_CELERY_BEAT"
echo ""