mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 19:53:11 +08:00
Compare commits
14 Commits
v1.5.4-dev
...
v1.5.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd1dd2c0d5 | ||
|
|
0b6560ac17 | ||
|
|
943a4cb960 | ||
|
|
eb2d853b76 | ||
|
|
1184c18b74 | ||
|
|
8a6f1b6f24 | ||
|
|
255d505aba | ||
|
|
d06a9bab1f | ||
|
|
6d5c776bf7 | ||
|
|
bf058dd67b | ||
|
|
0532d7c8b8 | ||
|
|
2ee9b5ffa2 | ||
|
|
648a1888d4 | ||
|
|
2508268a45 |
128
README.md
128
README.md
@@ -1,7 +1,7 @@
|
||||
<h1 align="center">XingRin - 星环</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>🛡️ 攻击面管理平台 (ASM) | 自动化资产发现与漏洞扫描系统</b>
|
||||
<b>攻击面管理平台 (ASM) | 自动化资产发现与漏洞扫描系统</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -12,29 +12,29 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-功能特性">功能特性</a> •
|
||||
<a href="#-全局资产搜索">资产搜索</a> •
|
||||
<a href="#-快速开始">快速开始</a> •
|
||||
<a href="#-文档">文档</a> •
|
||||
<a href="#-反馈与贡献">反馈与贡献</a>
|
||||
<a href="#功能特性">功能特性</a> •
|
||||
<a href="#全局资产搜索">资产搜索</a> •
|
||||
<a href="#快速开始">快速开始</a> •
|
||||
<a href="#文档">文档</a> •
|
||||
<a href="#反馈与贡献">反馈与贡献</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>🔍 关键词: ASM | 攻击面管理 | 漏洞扫描 | 资产发现 | 资产搜索 | Bug Bounty | 渗透测试 | Nuclei | 子域名枚举 | EASM</sub>
|
||||
<sub>关键词: ASM | 攻击面管理 | 漏洞扫描 | 资产发现 | 资产搜索 | Bug Bounty | 渗透测试 | Nuclei | 子域名枚举 | EASM</sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🌐 在线 Demo
|
||||
## 在线 Demo
|
||||
|
||||
**[https://xingrin.vercel.app/](https://xingrin.vercel.app/)**
|
||||
|
||||
> ⚠️ 仅用于 UI 展示,未接入后端数据库
|
||||
> 仅用于 UI 展示,未接入后端数据库
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<b>🎨 现代化 UI </b>
|
||||
<b>现代化 UI</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -44,45 +44,45 @@
|
||||
<img src="docs/screenshots/quantum-rose.png" alt="Quantum Rose" width="24%">
|
||||
</p>
|
||||
|
||||
## 📚 文档
|
||||
## 文档
|
||||
|
||||
- [📖 技术文档](./docs/README.md) - 技术文档导航(🚧 持续完善中)
|
||||
- [🚀 快速开始](./docs/quick-start.md) - 一键安装和部署指南
|
||||
- [🔄 版本管理](./docs/version-management.md) - Git Tag 驱动的自动化版本管理系统
|
||||
- [📦 Nuclei 模板架构](./docs/nuclei-template-architecture.md) - 模板仓库的存储与同步
|
||||
- [📖 字典文件架构](./docs/wordlist-architecture.md) - 字典文件的存储与同步
|
||||
- [🔍 扫描流程架构](./docs/scan-flow-architecture.md) - 完整扫描流程与工具编排
|
||||
- [技术文档](./docs/README.md) - 技术文档导航(持续完善中)
|
||||
- [快速开始](./docs/quick-start.md) - 一键安装和部署指南
|
||||
- [版本管理](./docs/version-management.md) - Git Tag 驱动的自动化版本管理系统
|
||||
- [Nuclei 模板架构](./docs/nuclei-template-architecture.md) - 模板仓库的存储与同步
|
||||
- [字典文件架构](./docs/wordlist-architecture.md) - 字典文件的存储与同步
|
||||
- [扫描流程架构](./docs/scan-flow-architecture.md) - 完整扫描流程与工具编排
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
## 功能特性
|
||||
|
||||
### 扫描能力
|
||||
|
||||
| 功能 | 状态 | 工具 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 子域名扫描 | ✅ | Subfinder, Amass, PureDNS | 被动收集 + 主动爆破,聚合 50+ 数据源 |
|
||||
| 端口扫描 | ✅ | Naabu | 自定义端口范围 |
|
||||
| 站点发现 | ✅ | HTTPX | HTTP 探测,自动获取标题、状态码、技术栈 |
|
||||
| 指纹识别 | ✅ | XingFinger | 2.7W+ 指纹规则,多源指纹库 |
|
||||
| URL 收集 | ✅ | Waymore, Katana | 历史数据 + 主动爬取 |
|
||||
| 目录扫描 | ✅ | FFUF | 高速爆破,智能字典 |
|
||||
| 漏洞扫描 | ✅ | Nuclei, Dalfox | 9000+ POC 模板,XSS 检测 |
|
||||
| 站点截图 | ✅ | Playwright | WebP 高压缩存储 |
|
||||
| 子域名扫描 | 已完成 | Subfinder, Amass, PureDNS | 被动收集 + 主动爆破,聚合 50+ 数据源 |
|
||||
| 端口扫描 | 已完成 | Naabu | 自定义端口范围 |
|
||||
| 站点发现 | 已完成 | HTTPX | HTTP 探测,自动获取标题、状态码、技术栈 |
|
||||
| 指纹识别 | 已完成 | XingFinger | 2.7W+ 指纹规则,多源指纹库 |
|
||||
| URL 收集 | 已完成 | Waymore, Katana | 历史数据 + 主动爬取 |
|
||||
| 目录扫描 | 已完成 | FFUF | 高速爆破,智能字典 |
|
||||
| 漏洞扫描 | 已完成 | Nuclei, Dalfox | 9000+ POC 模板,XSS 检测 |
|
||||
| 站点截图 | 已完成 | Playwright | WebP 高压缩存储 |
|
||||
|
||||
### 平台能力
|
||||
|
||||
| 功能 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 目标管理 | ✅ | 多层级组织,支持域名/IP 目标 |
|
||||
| 资产快照 | ✅ | 扫描结果对比,追踪资产变化 |
|
||||
| 黑名单过滤 | ✅ | 全局 + Target 级,支持通配符/CIDR |
|
||||
| 定时任务 | ✅ | Cron 表达式,自动化周期扫描 |
|
||||
| 分布式扫描 | ✅ | 多 Worker 节点,负载感知调度 |
|
||||
| 全局搜索 | ✅ | 表达式语法,多字段组合查询 |
|
||||
| 通知推送 | ✅ | 企业微信、Telegram、Discord |
|
||||
| API 密钥管理 | ✅ | 可视化配置各数据源 API Key |
|
||||
| 目标管理 | 已完成 | 多层级组织,支持域名/IP 目标 |
|
||||
| 资产快照 | 已完成 | 扫描结果对比,追踪资产变化 |
|
||||
| 黑名单过滤 | 已完成 | 全局 + Target 级,支持通配符/CIDR |
|
||||
| 定时任务 | 已完成 | Cron 表达式,自动化周期扫描 |
|
||||
| 分布式扫描 | 已完成 | 多 Worker 节点,负载感知调度 |
|
||||
| 全局搜索 | 已完成 | 表达式语法,多字段组合查询 |
|
||||
| 通知推送 | 已完成 | 企业微信、Telegram、Discord |
|
||||
| API 密钥管理 | 已完成 | 可视化配置各数据源 API Key |
|
||||
|
||||
### 扫描流程架构
|
||||
|
||||
@@ -136,7 +136,7 @@ flowchart LR
|
||||
|
||||
详细说明请查看 [扫描流程架构文档](./docs/scan-flow-architecture.md)
|
||||
|
||||
### 🖥️ 分布式架构
|
||||
### 分布式架构
|
||||
- **多节点扫描** - 支持部署多个 Worker 节点,横向扩展扫描能力
|
||||
- **本地节点** - 零配置,安装即自动注册本地 Docker Worker
|
||||
- **远程节点** - SSH 一键部署远程 VPS 作为扫描节点
|
||||
@@ -181,7 +181,7 @@ flowchart TB
|
||||
W3 -.心跳上报.-> REDIS
|
||||
```
|
||||
|
||||
### 🔎 全局资产搜索
|
||||
### 全局资产搜索
|
||||
- **多类型搜索** - 支持 Website 和 Endpoint 两种资产类型
|
||||
- **表达式语法** - 支持 `=`(模糊)、`==`(精确)、`!=`(不等于)操作符
|
||||
- **逻辑组合** - 支持 `&&` (AND) 和 `||` (OR) 逻辑组合
|
||||
@@ -205,14 +205,14 @@ host="admin" && tech="php" && status=="200"
|
||||
url="/api/v1" && status!="404"
|
||||
```
|
||||
|
||||
### 📊 可视化界面
|
||||
### 可视化界面
|
||||
- **数据统计** - 资产/漏洞统计仪表盘
|
||||
- **实时通知** - WebSocket 消息推送
|
||||
- **通知推送** - 实时企业微信,tg,discard消息推送服务
|
||||
|
||||
---
|
||||
|
||||
## 📦 快速开始
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
@@ -230,11 +230,11 @@ cd xingrin
|
||||
# 安装并启动(生产模式)
|
||||
sudo ./install.sh
|
||||
|
||||
# 🇨🇳 中国大陆用户推荐使用镜像加速(第三方加速服务可能会失效,不保证长期可用)
|
||||
# 中国大陆用户推荐使用镜像加速(第三方加速服务可能会失效,不保证长期可用)
|
||||
sudo ./install.sh --mirror
|
||||
```
|
||||
|
||||
> **💡 --mirror 参数说明**
|
||||
> **--mirror 参数说明**
|
||||
> - 自动配置 Docker 镜像加速(国内镜像源)
|
||||
> - 加速 Git 仓库克隆(Nuclei 模板等)
|
||||
|
||||
@@ -259,17 +259,17 @@ sudo ./restart.sh
|
||||
sudo ./uninstall.sh
|
||||
```
|
||||
|
||||
## 🤝 反馈与贡献
|
||||
## 反馈与贡献
|
||||
|
||||
- 💡 **发现 Bug,有新想法,比如UI设计,功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues) 或者公众号私信
|
||||
- **发现 Bug,有新想法,比如UI设计,功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues) 或者公众号私信
|
||||
|
||||
## 📧 联系
|
||||
## 联系
|
||||
- 微信公众号: **塔罗安全学苑**
|
||||
- 微信群去公众号底下的菜单,有个交流群,点击就可以看到了,链接过期可以私信我拉你
|
||||
|
||||
<img src="docs/wechat-qrcode.png" alt="微信公众号" width="200">
|
||||
|
||||
### 🎁 关注公众号免费领取指纹库
|
||||
### 关注公众号免费领取指纹库
|
||||
|
||||
| 指纹库 | 数量 |
|
||||
|--------|------|
|
||||
@@ -278,9 +278,9 @@ sudo ./uninstall.sh
|
||||
| goby.json | 7,086 |
|
||||
| FingerprintHub.json | 3,147 |
|
||||
|
||||
> 💡 关注公众号回复「指纹」即可获取
|
||||
> 关注公众号回复「指纹」即可获取
|
||||
|
||||
## ☕ 赞助支持
|
||||
## 赞助支持
|
||||
|
||||
如果这个项目对你有帮助,谢谢请我能喝杯蜜雪冰城,你的star和赞助是我免费更新的动力
|
||||
|
||||
@@ -289,14 +289,9 @@ sudo ./uninstall.sh
|
||||
<img src="docs/zfb_pay.jpg" alt="支付宝" width="200">
|
||||
</p>
|
||||
|
||||
### 🙏 感谢以下赞助
|
||||
|
||||
| 昵称 | 金额 |
|
||||
|------|------|
|
||||
| X(闭关中) | ¥88 |
|
||||
|
||||
|
||||
## ⚠️ 免责声明
|
||||
## 免责声明
|
||||
|
||||
**重要:请在使用前仔细阅读**
|
||||
|
||||
@@ -311,30 +306,29 @@ sudo ./uninstall.sh
|
||||
- 遵守所在地区的法律法规
|
||||
- 承担因滥用产生的一切后果
|
||||
|
||||
## 🌟 Star History
|
||||
## Star History
|
||||
|
||||
如果这个项目对你有帮助,请给一个 ⭐ Star 支持一下!
|
||||
如果这个项目对你有帮助,请给一个 Star 支持一下!
|
||||
|
||||
[](https://star-history.com/#yyhuni/xingrin&Date)
|
||||
|
||||
## 📄 许可证
|
||||
## 许可证
|
||||
|
||||
本项目采用 [GNU General Public License v3.0](LICENSE) 许可证。
|
||||
|
||||
### 允许的用途
|
||||
|
||||
- ✅ 个人学习和研究
|
||||
- ✅ 商业和非商业使用
|
||||
- ✅ 修改和分发
|
||||
- ✅ 专利使用
|
||||
- ✅ 私人使用
|
||||
- 个人学习和研究
|
||||
- 商业和非商业使用
|
||||
- 修改和分发
|
||||
- 专利使用
|
||||
- 私人使用
|
||||
|
||||
### 义务和限制
|
||||
|
||||
- 📋 **开源义务**:分发时必须提供源代码
|
||||
- 📋 **相同许可**:衍生作品必须使用相同许可证
|
||||
- 📋 **版权声明**:必须保留原始版权和许可证声明
|
||||
- ❌ **责任免除**:不提供任何担保
|
||||
- ❌ 未经授权的渗透测试
|
||||
- ❌ 任何违法行为
|
||||
|
||||
- **开源义务**:分发时必须提供源代码
|
||||
- **相同许可**:衍生作品必须使用相同许可证
|
||||
- **版权声明**:必须保留原始版权和许可证声明
|
||||
- **责任免除**:不提供任何担保
|
||||
- 未经授权的渗透测试
|
||||
- 任何违法行为
|
||||
|
||||
@@ -14,6 +14,7 @@ from .views import (
|
||||
LoginView, LogoutView, MeView, ChangePasswordView,
|
||||
SystemLogsView, SystemLogFilesView, HealthCheckView,
|
||||
GlobalBlacklistView,
|
||||
VersionView, CheckUpdateView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -29,6 +30,8 @@ urlpatterns = [
|
||||
# 系统管理
|
||||
path('system/logs/', SystemLogsView.as_view(), name='system-logs'),
|
||||
path('system/logs/files/', SystemLogFilesView.as_view(), name='system-log-files'),
|
||||
path('system/version/', VersionView.as_view(), name='system-version'),
|
||||
path('system/check-update/', CheckUpdateView.as_view(), name='system-check-update'),
|
||||
|
||||
# 黑名单管理(PUT 全量替换模式)
|
||||
path('blacklist/rules/', GlobalBlacklistView.as_view(), name='blacklist-rules'),
|
||||
|
||||
@@ -6,16 +6,19 @@
|
||||
- 认证相关视图:登录、登出、用户信息、修改密码
|
||||
- 系统日志视图:实时日志查看
|
||||
- 黑名单视图:全局黑名单规则管理
|
||||
- 版本视图:系统版本和更新检查
|
||||
"""
|
||||
|
||||
from .health_views import HealthCheckView
|
||||
from .auth_views import LoginView, LogoutView, MeView, ChangePasswordView
|
||||
from .system_log_views import SystemLogsView, SystemLogFilesView
|
||||
from .blacklist_views import GlobalBlacklistView
|
||||
from .version_views import VersionView, CheckUpdateView
|
||||
|
||||
__all__ = [
|
||||
'HealthCheckView',
|
||||
'LoginView', 'LogoutView', 'MeView', 'ChangePasswordView',
|
||||
'SystemLogsView', 'SystemLogFilesView',
|
||||
'GlobalBlacklistView',
|
||||
'VersionView', 'CheckUpdateView',
|
||||
]
|
||||
|
||||
136
backend/apps/common/views/version_views.py
Normal file
136
backend/apps/common/views/version_views.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
系统版本相关视图
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.common.error_codes import ErrorCodes
|
||||
from apps.common.response_helpers import error_response, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# GitHub 仓库信息
|
||||
GITHUB_REPO = "yyhuni/xingrin"
|
||||
GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
||||
GITHUB_RELEASES_URL = f"https://github.com/{GITHUB_REPO}/releases"
|
||||
|
||||
|
||||
def get_current_version() -> str:
|
||||
"""读取当前版本号"""
|
||||
import os
|
||||
|
||||
# 方式1:从环境变量读取(Docker 容器中推荐)
|
||||
version = os.environ.get('IMAGE_TAG', '')
|
||||
if version:
|
||||
return version
|
||||
|
||||
# 方式2:从文件读取(开发环境)
|
||||
possible_paths = [
|
||||
Path('/app/VERSION'),
|
||||
Path(__file__).parent.parent.parent.parent.parent / 'VERSION',
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
try:
|
||||
return path.read_text(encoding='utf-8').strip()
|
||||
except (FileNotFoundError, OSError):
|
||||
continue
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def compare_versions(current: str, latest: str) -> bool:
|
||||
"""
|
||||
比较版本号,判断是否有更新
|
||||
|
||||
Returns:
|
||||
True 表示有更新可用
|
||||
"""
|
||||
def parse_version(v: str) -> tuple:
|
||||
v = v.lstrip('v')
|
||||
parts = v.split('.')
|
||||
result = []
|
||||
for part in parts:
|
||||
if '-' in part:
|
||||
num, _ = part.split('-', 1)
|
||||
result.append(int(num))
|
||||
else:
|
||||
result.append(int(part))
|
||||
return tuple(result)
|
||||
|
||||
try:
|
||||
return parse_version(latest) > parse_version(current)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
class VersionView(APIView):
|
||||
"""获取当前系统版本"""
|
||||
|
||||
def get(self, _request: Request) -> Response:
|
||||
"""获取当前版本信息"""
|
||||
return success_response(data={
|
||||
'version': get_current_version(),
|
||||
'github_repo': GITHUB_REPO,
|
||||
})
|
||||
|
||||
|
||||
class CheckUpdateView(APIView):
|
||||
"""检查系统更新"""
|
||||
|
||||
def get(self, _request: Request) -> Response:
|
||||
"""
|
||||
检查是否有新版本
|
||||
|
||||
Returns:
|
||||
- current_version: 当前版本
|
||||
- latest_version: 最新版本
|
||||
- has_update: 是否有更新
|
||||
- release_url: 发布页面 URL
|
||||
- release_notes: 更新说明(如果有)
|
||||
"""
|
||||
current_version = get_current_version()
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
GITHUB_API_URL,
|
||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
return success_response(data={
|
||||
'current_version': current_version,
|
||||
'latest_version': current_version,
|
||||
'has_update': False,
|
||||
'release_url': GITHUB_RELEASES_URL,
|
||||
'release_notes': None,
|
||||
})
|
||||
|
||||
response.raise_for_status()
|
||||
release_data = response.json()
|
||||
|
||||
latest_version = release_data.get('tag_name', current_version)
|
||||
has_update = compare_versions(current_version, latest_version)
|
||||
|
||||
return success_response(data={
|
||||
'current_version': current_version,
|
||||
'latest_version': latest_version,
|
||||
'has_update': has_update,
|
||||
'release_url': release_data.get('html_url', GITHUB_RELEASES_URL),
|
||||
'release_notes': release_data.get('body'),
|
||||
'published_at': release_data.get('published_at'),
|
||||
})
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning("检查更新失败: %s", e)
|
||||
return error_response(
|
||||
code=ErrorCodes.SERVER_ERROR,
|
||||
message="无法连接到 GitHub,请稍后重试",
|
||||
)
|
||||
@@ -2,8 +2,9 @@
|
||||
初始化默认扫描引擎
|
||||
|
||||
用法:
|
||||
python manage.py init_default_engine # 只创建不存在的引擎(不覆盖已有)
|
||||
python manage.py init_default_engine --force # 强制覆盖所有引擎配置
|
||||
python manage.py init_default_engine # 只创建不存在的引擎(不覆盖已有)
|
||||
python manage.py init_default_engine --force # 强制覆盖所有引擎配置
|
||||
python manage.py init_default_engine --force-sub # 只覆盖子引擎,保留 full scan
|
||||
|
||||
cd /root/my-vulun-scan/docker
|
||||
docker compose exec server python backend/manage.py init_default_engine --force
|
||||
@@ -12,6 +13,7 @@
|
||||
- 读取 engine_config_example.yaml 作为默认配置
|
||||
- 创建 full scan(默认引擎)+ 各扫描类型的子引擎
|
||||
- 默认不覆盖已有配置,加 --force 才会覆盖
|
||||
- 加 --force-sub 只覆盖子引擎配置,保留用户自定义的 full scan
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
@@ -30,11 +32,18 @@ class Command(BaseCommand):
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='强制覆盖已有的引擎配置',
|
||||
help='强制覆盖已有的引擎配置(包括 full scan 和子引擎)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force-sub',
|
||||
action='store_true',
|
||||
help='只覆盖子引擎配置,保留 full scan(升级时使用)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
force = options.get('force', False)
|
||||
force_sub = options.get('force_sub', False)
|
||||
|
||||
# 读取默认配置文件
|
||||
config_path = Path(__file__).resolve().parent.parent.parent.parent / 'scan' / 'configs' / 'engine_config_example.yaml'
|
||||
|
||||
@@ -99,15 +108,22 @@ class Command(BaseCommand):
|
||||
engine_name = f"{scan_type}"
|
||||
sub_engine = ScanEngine.objects.filter(name=engine_name).first()
|
||||
if sub_engine:
|
||||
if force:
|
||||
# force 或 force_sub 都会覆盖子引擎
|
||||
if force or force_sub:
|
||||
sub_engine.configuration = single_yaml
|
||||
sub_engine.save()
|
||||
self.stdout.write(self.style.SUCCESS(f' ✓ 子引擎 {engine_name} 配置已更新 (ID: {sub_engine.id})'))
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f' ✓ 子引擎 {engine_name} 配置已更新 (ID: {sub_engine.id})'
|
||||
))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(f' ⊘ {engine_name} 已存在,跳过(使用 --force 覆盖)'))
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f' ⊘ {engine_name} 已存在,跳过(使用 --force 覆盖)'
|
||||
))
|
||||
else:
|
||||
sub_engine = ScanEngine.objects.create(
|
||||
name=engine_name,
|
||||
configuration=single_yaml,
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f' ✓ 子引擎 {engine_name} 已创建 (ID: {sub_engine.id})'))
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f' ✓ 子引擎 {engine_name} 已创建 (ID: {sub_engine.id})'
|
||||
))
|
||||
|
||||
23
backend/apps/scan/migrations/0003_add_wecom_fields.py
Normal file
23
backend/apps/scan/migrations/0003_add_wecom_fields.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated manually for WeCom notification support
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('scan', '0002_add_cached_screenshots_count'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notificationsettings',
|
||||
name='wecom_enabled',
|
||||
field=models.BooleanField(default=False, help_text='是否启用企业微信通知'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='notificationsettings',
|
||||
name='wecom_webhook_url',
|
||||
field=models.URLField(blank=True, default='', help_text='企业微信机器人 Webhook URL'),
|
||||
),
|
||||
]
|
||||
@@ -1,8 +1,14 @@
|
||||
"""通知系统数据模型"""
|
||||
|
||||
from django.db import models
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from .types import NotificationLevel, NotificationCategory
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from .types import NotificationCategory, NotificationLevel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationSettings(models.Model):
|
||||
@@ -10,31 +16,34 @@ class NotificationSettings(models.Model):
|
||||
通知设置(单例模型)
|
||||
存储 Discord webhook 配置和各分类的通知开关
|
||||
"""
|
||||
|
||||
|
||||
# Discord 配置
|
||||
discord_enabled = models.BooleanField(default=False, help_text='是否启用 Discord 通知')
|
||||
discord_webhook_url = models.URLField(blank=True, default='', help_text='Discord Webhook URL')
|
||||
|
||||
|
||||
# 企业微信配置
|
||||
wecom_enabled = models.BooleanField(default=False, help_text='是否启用企业微信通知')
|
||||
wecom_webhook_url = models.URLField(blank=True, default='', help_text='企业微信机器人 Webhook URL')
|
||||
|
||||
# 分类开关(使用 JSONField 存储)
|
||||
categories = models.JSONField(
|
||||
default=dict,
|
||||
help_text='各分类通知开关,如 {"scan": true, "vulnerability": true, "asset": true, "system": false}'
|
||||
)
|
||||
|
||||
|
||||
# 时间信息
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
db_table = 'notification_settings'
|
||||
verbose_name = '通知设置'
|
||||
verbose_name_plural = '通知设置'
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# 单例模式:强制只有一条记录
|
||||
self.pk = 1
|
||||
self.pk = 1 # 单例模式
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'NotificationSettings':
|
||||
"""获取或创建单例实例"""
|
||||
@@ -52,7 +61,7 @@ class NotificationSettings(models.Model):
|
||||
}
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def is_category_enabled(self, category: str) -> bool:
|
||||
"""检查指定分类是否启用通知"""
|
||||
return self.categories.get(category, False)
|
||||
@@ -60,10 +69,9 @@ class NotificationSettings(models.Model):
|
||||
|
||||
class Notification(models.Model):
|
||||
"""通知模型"""
|
||||
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
|
||||
# 通知分类
|
||||
|
||||
category = models.CharField(
|
||||
max_length=20,
|
||||
choices=NotificationCategory.choices,
|
||||
@@ -71,8 +79,7 @@ class Notification(models.Model):
|
||||
db_index=True,
|
||||
help_text='通知分类'
|
||||
)
|
||||
|
||||
# 通知级别
|
||||
|
||||
level = models.CharField(
|
||||
max_length=20,
|
||||
choices=NotificationLevel.choices,
|
||||
@@ -80,16 +87,15 @@ class Notification(models.Model):
|
||||
db_index=True,
|
||||
help_text='通知级别'
|
||||
)
|
||||
|
||||
|
||||
title = models.CharField(max_length=200, help_text='通知标题')
|
||||
message = models.CharField(max_length=2000, help_text='通知内容')
|
||||
|
||||
# 时间信息
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, help_text='创建时间')
|
||||
|
||||
|
||||
is_read = models.BooleanField(default=False, help_text='是否已读')
|
||||
read_at = models.DateTimeField(null=True, blank=True, help_text='阅读时间')
|
||||
|
||||
|
||||
class Meta:
|
||||
db_table = 'notification'
|
||||
verbose_name = '通知'
|
||||
@@ -101,44 +107,26 @@ class Notification(models.Model):
|
||||
models.Index(fields=['level', '-created_at']),
|
||||
models.Index(fields=['is_read', '-created_at']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_level_display()} - {self.title}"
|
||||
|
||||
|
||||
@classmethod
|
||||
def cleanup_old_notifications(cls):
|
||||
"""
|
||||
清理超过15天的旧通知(硬编码)
|
||||
|
||||
Returns:
|
||||
int: 删除的通知数量
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
# 硬编码:只保留最近15天的通知
|
||||
def cleanup_old_notifications(cls) -> int:
|
||||
"""清理超过15天的旧通知"""
|
||||
cutoff_date = timezone.now() - timedelta(days=15)
|
||||
delete_result = cls.objects.filter(created_at__lt=cutoff_date).delete()
|
||||
|
||||
return delete_result[0] if delete_result[0] else 0
|
||||
|
||||
deleted_count, _ = cls.objects.filter(created_at__lt=cutoff_date).delete()
|
||||
return deleted_count or 0
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
重写save方法,在创建新通知时自动清理旧通知
|
||||
"""
|
||||
"""重写save方法,在创建新通知时自动清理旧通知"""
|
||||
is_new = self.pk is None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# 只在创建新通知时执行清理(自动清理超过15天的通知)
|
||||
|
||||
if is_new:
|
||||
try:
|
||||
deleted_count = self.__class__.cleanup_old_notifications()
|
||||
if deleted_count > 0:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"自动清理了 {deleted_count} 条超过15天的旧通知")
|
||||
except Exception as e:
|
||||
# 清理失败不应影响通知创建
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"通知自动清理失败: {e}")
|
||||
logger.info("自动清理了 %d 条超过15天的旧通知", deleted_count)
|
||||
except Exception:
|
||||
logger.warning("通知自动清理失败", exc_info=True)
|
||||
|
||||
@@ -1,52 +1,70 @@
|
||||
"""通知系统仓储层模块"""
|
||||
|
||||
import logging
|
||||
from typing import TypedDict
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.common.decorators import auto_ensure_db_connection
|
||||
from .models import Notification, NotificationSettings
|
||||
|
||||
from .models import Notification, NotificationSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationSettingsData(TypedDict):
|
||||
"""通知设置数据结构"""
|
||||
@dataclass
|
||||
class NotificationSettingsData:
|
||||
"""通知设置更新数据"""
|
||||
|
||||
discord_enabled: bool
|
||||
discord_webhook_url: str
|
||||
categories: dict[str, bool]
|
||||
wecom_enabled: bool = False
|
||||
wecom_webhook_url: str = ''
|
||||
|
||||
|
||||
@auto_ensure_db_connection
|
||||
class NotificationSettingsRepository:
|
||||
"""通知设置仓储层"""
|
||||
|
||||
|
||||
def get_settings(self) -> NotificationSettings:
|
||||
"""获取通知设置单例"""
|
||||
return NotificationSettings.get_instance()
|
||||
|
||||
def update_settings(
|
||||
self,
|
||||
discord_enabled: bool,
|
||||
discord_webhook_url: str,
|
||||
categories: dict[str, bool]
|
||||
) -> NotificationSettings:
|
||||
|
||||
def update_settings(self, data: NotificationSettingsData) -> NotificationSettings:
|
||||
"""更新通知设置"""
|
||||
settings = NotificationSettings.get_instance()
|
||||
settings.discord_enabled = discord_enabled
|
||||
settings.discord_webhook_url = discord_webhook_url
|
||||
settings.categories = categories
|
||||
settings.discord_enabled = data.discord_enabled
|
||||
settings.discord_webhook_url = data.discord_webhook_url
|
||||
settings.wecom_enabled = data.wecom_enabled
|
||||
settings.wecom_webhook_url = data.wecom_webhook_url
|
||||
settings.categories = data.categories
|
||||
settings.save()
|
||||
return settings
|
||||
|
||||
|
||||
def is_category_enabled(self, category: str) -> bool:
|
||||
"""检查指定分类是否启用"""
|
||||
settings = self.get_settings()
|
||||
return settings.is_category_enabled(category)
|
||||
return self.get_settings().is_category_enabled(category)
|
||||
|
||||
|
||||
@auto_ensure_db_connection
|
||||
class DjangoNotificationRepository:
|
||||
def get_filtered(self, level: str | None = None, unread: bool | None = None):
|
||||
"""通知数据仓储层"""
|
||||
|
||||
def get_filtered(
|
||||
self,
|
||||
level: Optional[str] = None,
|
||||
unread: Optional[bool] = None
|
||||
) -> QuerySet[Notification]:
|
||||
"""
|
||||
获取过滤后的通知列表
|
||||
|
||||
Args:
|
||||
level: 通知级别过滤
|
||||
unread: 已读状态过滤 (True=未读, False=已读, None=全部)
|
||||
"""
|
||||
queryset = Notification.objects.all()
|
||||
|
||||
if level:
|
||||
@@ -60,16 +78,24 @@ class DjangoNotificationRepository:
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
def get_unread_count(self) -> int:
|
||||
"""获取未读通知数量"""
|
||||
return Notification.objects.filter(is_read=False).count()
|
||||
|
||||
def mark_all_as_read(self) -> int:
|
||||
updated = Notification.objects.filter(is_read=False).update(
|
||||
"""标记所有通知为已读,返回更新数量"""
|
||||
return Notification.objects.filter(is_read=False).update(
|
||||
is_read=True,
|
||||
read_at=timezone.now(),
|
||||
)
|
||||
return updated
|
||||
|
||||
def create(self, title: str, message: str, level: str, category: str = 'system') -> Notification:
|
||||
def create(
|
||||
self,
|
||||
title: str,
|
||||
message: str,
|
||||
level: str,
|
||||
category: str = 'system'
|
||||
) -> Notification:
|
||||
"""创建新通知"""
|
||||
return Notification.objects.create(
|
||||
category=category,
|
||||
level=level,
|
||||
|
||||
@@ -60,13 +60,12 @@ def push_to_external_channels(notification: Notification) -> None:
|
||||
except Exception as e:
|
||||
logger.warning(f"Discord 推送失败: {e}")
|
||||
|
||||
# 未来扩展:Slack
|
||||
# if settings.slack_enabled and settings.slack_webhook_url:
|
||||
# _send_slack(notification, settings.slack_webhook_url)
|
||||
|
||||
# 未来扩展:Telegram
|
||||
# if settings.telegram_enabled and settings.telegram_bot_token:
|
||||
# _send_telegram(notification, settings.telegram_chat_id)
|
||||
# 企业微信渠道
|
||||
if settings.wecom_enabled and settings.wecom_webhook_url:
|
||||
try:
|
||||
_send_wecom(notification, settings.wecom_webhook_url)
|
||||
except Exception as e:
|
||||
logger.warning(f"企业微信推送失败: {e}")
|
||||
|
||||
|
||||
def _send_discord(notification: Notification, webhook_url: str) -> bool:
|
||||
@@ -103,6 +102,41 @@ def _send_discord(notification: Notification, webhook_url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _send_wecom(notification: Notification, webhook_url: str) -> bool:
|
||||
"""发送到企业微信机器人 Webhook"""
|
||||
try:
|
||||
emoji = CATEGORY_EMOJI.get(notification.category, '📢')
|
||||
|
||||
# 企业微信 Markdown 格式
|
||||
content = f"""**{emoji} {notification.title}**
|
||||
> 级别:{notification.get_level_display()}
|
||||
> 分类:{notification.get_category_display()}
|
||||
|
||||
{notification.message}"""
|
||||
|
||||
payload = {
|
||||
'msgtype': 'markdown',
|
||||
'markdown': {'content': content}
|
||||
}
|
||||
|
||||
response = requests.post(webhook_url, json=payload, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get('errcode') == 0:
|
||||
logger.info(f"企业微信通知发送成功 - {notification.title}")
|
||||
return True
|
||||
logger.warning(f"企业微信发送失败 - errcode: {result.get('errcode')}, errmsg: {result.get('errmsg')}")
|
||||
return False
|
||||
|
||||
logger.warning(f"企业微信发送失败 - 状态码: {response.status_code}")
|
||||
return False
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"企业微信网络错误: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 设置服务
|
||||
# ============================================================
|
||||
@@ -121,31 +155,43 @@ class NotificationSettingsService:
|
||||
'enabled': settings.discord_enabled,
|
||||
'webhookUrl': settings.discord_webhook_url,
|
||||
},
|
||||
'wecom': {
|
||||
'enabled': settings.wecom_enabled,
|
||||
'webhookUrl': settings.wecom_webhook_url,
|
||||
},
|
||||
'categories': settings.categories,
|
||||
}
|
||||
|
||||
def update_settings(self, data: dict) -> dict:
|
||||
"""更新通知设置
|
||||
|
||||
|
||||
注意:DRF CamelCaseJSONParser 会将前端的 webhookUrl 转换为 webhook_url
|
||||
"""
|
||||
discord_data = data.get('discord', {})
|
||||
wecom_data = data.get('wecom', {})
|
||||
categories = data.get('categories', {})
|
||||
|
||||
|
||||
# CamelCaseJSONParser 转换后的字段名是 webhook_url
|
||||
webhook_url = discord_data.get('webhook_url', '')
|
||||
|
||||
discord_webhook_url = discord_data.get('webhook_url', '')
|
||||
wecom_webhook_url = wecom_data.get('webhook_url', '')
|
||||
|
||||
settings = self.repo.update_settings(
|
||||
discord_enabled=discord_data.get('enabled', False),
|
||||
discord_webhook_url=webhook_url,
|
||||
discord_webhook_url=discord_webhook_url,
|
||||
wecom_enabled=wecom_data.get('enabled', False),
|
||||
wecom_webhook_url=wecom_webhook_url,
|
||||
categories=categories,
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
'discord': {
|
||||
'enabled': settings.discord_enabled,
|
||||
'webhookUrl': settings.discord_webhook_url,
|
||||
},
|
||||
'wecom': {
|
||||
'enabled': settings.wecom_enabled,
|
||||
'webhookUrl': settings.wecom_webhook_url,
|
||||
},
|
||||
'categories': settings.categories,
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ services:
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- IMAGE_TAG=${IMAGE_TAG:-dev}
|
||||
ports:
|
||||
- "8888:8888"
|
||||
depends_on:
|
||||
|
||||
@@ -48,6 +48,8 @@ services:
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- IMAGE_TAG=${IMAGE_TAG}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -83,20 +83,20 @@ if not yaml_path.exists():
|
||||
print('未找到配置文件,跳过')
|
||||
exit(0)
|
||||
|
||||
new_config = yaml_path.read_text()
|
||||
|
||||
# 检查是否已有 full scan 引擎
|
||||
engine = ScanEngine.objects.filter(name='full scan').first()
|
||||
if engine:
|
||||
if not engine.configuration or not engine.configuration.strip():
|
||||
engine.configuration = yaml_path.read_text()
|
||||
engine.save(update_fields=['configuration'])
|
||||
print(f'已初始化引擎配置: {engine.name}')
|
||||
else:
|
||||
print(f'引擎已有配置,跳过')
|
||||
# 直接覆盖为最新配置
|
||||
engine.configuration = new_config
|
||||
engine.save(update_fields=['configuration'])
|
||||
print(f'已更新引擎配置: {engine.name}')
|
||||
else:
|
||||
# 创建引擎
|
||||
engine = ScanEngine.objects.create(
|
||||
name='full scan',
|
||||
configuration=yaml_path.read_text(),
|
||||
configuration=new_config,
|
||||
)
|
||||
print(f'已创建引擎: {engine.name}')
|
||||
"
|
||||
|
||||
@@ -10,7 +10,7 @@ python manage.py migrate --noinput
|
||||
echo " ✓ 数据库迁移完成"
|
||||
|
||||
echo " [1.1/3] 初始化默认扫描引擎..."
|
||||
python manage.py init_default_engine
|
||||
python manage.py init_default_engine --force
|
||||
echo " ✓ 默认扫描引擎已就绪"
|
||||
|
||||
echo " [1.2/3] 初始化默认目录字典..."
|
||||
|
||||
@@ -182,7 +182,7 @@ echo -e "${BOLD}${GREEN}══════════════════
|
||||
echo ""
|
||||
echo -e "${BOLD}访问地址${NC}"
|
||||
if [ "$WITH_FRONTEND" = true ]; then
|
||||
echo -e " XingRin: ${CYAN}https://${ACCESS_HOST}/${NC}"
|
||||
echo -e " XingRin: ${CYAN}https://${ACCESS_HOST}:8083/${NC}"
|
||||
echo -e " ${YELLOW}(HTTP 会自动跳转到 HTTPS)${NC}"
|
||||
else
|
||||
echo -e " API: ${CYAN}通过前端或 nginx 访问(后端未暴露 8888)${NC}"
|
||||
@@ -191,8 +191,3 @@ else
|
||||
echo " cd frontend && pnpm dev"
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${BOLD}默认账号${NC}"
|
||||
echo " 用户名: admin"
|
||||
echo " 密码: admin"
|
||||
echo -e " ${YELLOW}[!] 请首次登录后修改密码${NC}"
|
||||
echo ""
|
||||
|
||||
@@ -29,6 +29,10 @@ export default function NotificationSettingsPage() {
|
||||
enabled: z.boolean(),
|
||||
webhookUrl: z.string().url(t("discord.urlInvalid")).or(z.literal('')),
|
||||
}),
|
||||
wecom: z.object({
|
||||
enabled: z.boolean(),
|
||||
webhookUrl: z.string().url(t("wecom.urlInvalid")).or(z.literal('')),
|
||||
}),
|
||||
categories: z.object({
|
||||
scan: z.boolean(),
|
||||
vulnerability: z.boolean(),
|
||||
@@ -46,6 +50,15 @@ export default function NotificationSettingsPage() {
|
||||
})
|
||||
}
|
||||
}
|
||||
if (val.wecom.enabled) {
|
||||
if (!val.wecom.webhookUrl || val.wecom.webhookUrl.trim() === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("wecom.requiredError"),
|
||||
path: ['wecom', 'webhookUrl'],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const NOTIFICATION_CATEGORIES = [
|
||||
@@ -79,6 +92,7 @@ export default function NotificationSettingsPage() {
|
||||
resolver: zodResolver(schema),
|
||||
values: data ?? {
|
||||
discord: { enabled: false, webhookUrl: '' },
|
||||
wecom: { enabled: false, webhookUrl: '' },
|
||||
categories: {
|
||||
scan: true,
|
||||
vulnerability: true,
|
||||
@@ -93,6 +107,7 @@ export default function NotificationSettingsPage() {
|
||||
}
|
||||
|
||||
const discordEnabled = form.watch('discord.enabled')
|
||||
const wecomEnabled = form.watch('wecom.enabled')
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
@@ -187,25 +202,59 @@ export default function NotificationSettingsPage() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Feishu/DingTalk/WeCom - Coming soon */}
|
||||
<Card className="opacity-60">
|
||||
{/* 企业微信 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<IconBrandSlack className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#07C160]/10">
|
||||
<IconBrandSlack className="h-5 w-5 text-[#07C160]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{t("enterprise.title")}</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">{t("emailChannel.comingSoon")}</Badge>
|
||||
</div>
|
||||
<CardDescription>{t("enterprise.description")}</CardDescription>
|
||||
<CardTitle className="text-base">{t("wecom.title")}</CardTitle>
|
||||
<CardDescription>{t("wecom.description")}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Switch disabled />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wecom.enabled"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isLoading || updateMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{wecomEnabled && (
|
||||
<CardContent className="pt-0">
|
||||
<Separator className="mb-4" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wecom.webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("wecom.webhookLabel")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("wecom.webhookPlaceholder")}
|
||||
{...field}
|
||||
disabled={isLoading || updateMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("wecom.webhookHelp")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
189
frontend/components/about-dialog.tsx
Normal file
189
frontend/components/about-dialog.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
IconRadar,
|
||||
IconRefresh,
|
||||
IconExternalLink,
|
||||
IconBrandGithub,
|
||||
IconMessageReport,
|
||||
IconBook,
|
||||
IconFileText,
|
||||
IconCheck,
|
||||
IconArrowUp,
|
||||
} from '@tabler/icons-react'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useVersion } from '@/hooks/use-version'
|
||||
import { VersionService } from '@/services/version.service'
|
||||
import type { UpdateCheckResult } from '@/types/version.types'
|
||||
|
||||
interface AboutDialogProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AboutDialog({ children }: AboutDialogProps) {
|
||||
const t = useTranslations('about')
|
||||
const { data: versionData } = useVersion()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [updateResult, setUpdateResult] = useState<UpdateCheckResult | null>(null)
|
||||
const [checkError, setCheckError] = useState<string | null>(null)
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setIsChecking(true)
|
||||
setCheckError(null)
|
||||
try {
|
||||
const result = await VersionService.checkUpdate()
|
||||
setUpdateResult(result)
|
||||
queryClient.setQueryData(['check-update'], result)
|
||||
} catch {
|
||||
setCheckError(t('checkFailed'))
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const currentVersion = updateResult?.currentVersion || versionData?.version || '-'
|
||||
const latestVersion = updateResult?.latestVersion
|
||||
const hasUpdate = updateResult?.hasUpdate
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Logo and name */}
|
||||
<div className="flex flex-col items-center py-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 mb-3">
|
||||
<IconRadar className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">XingRin</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
|
||||
{/* Version info */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{t('currentVersion')}</span>
|
||||
<span className="font-mono text-sm">{currentVersion}</span>
|
||||
</div>
|
||||
|
||||
{updateResult && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{t('latestVersion')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{latestVersion}</span>
|
||||
{hasUpdate ? (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<IconArrowUp className="h-3 w-3" />
|
||||
{t('updateAvailable')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<IconCheck className="h-3 w-3" />
|
||||
{t('upToDate')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkError && (
|
||||
<p className="text-sm text-destructive">{checkError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<IconRefresh className={`h-4 w-4 mr-2 ${isChecking ? 'animate-spin' : ''}`} />
|
||||
{isChecking ? t('checking') : t('checkUpdate')}
|
||||
</Button>
|
||||
|
||||
{hasUpdate && updateResult?.releaseUrl && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
asChild
|
||||
>
|
||||
<a href={updateResult.releaseUrl} target="_blank" rel="noopener noreferrer">
|
||||
<IconExternalLink className="h-4 w-4 mr-2" />
|
||||
{t('viewRelease')}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasUpdate && (
|
||||
<div className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
<p>{t('updateHint')}</p>
|
||||
<code className="mt-1 block rounded bg-background px-2 py-1 font-mono text-xs">
|
||||
sudo ./update.sh
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Links */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="ghost" size="sm" className="justify-start" asChild>
|
||||
<a href="https://github.com/yyhuni/xingrin" target="_blank" rel="noopener noreferrer">
|
||||
<IconBrandGithub className="h-4 w-4 mr-2" />
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="justify-start" asChild>
|
||||
<a href="https://github.com/yyhuni/xingrin/releases" target="_blank" rel="noopener noreferrer">
|
||||
<IconFileText className="h-4 w-4 mr-2" />
|
||||
{t('changelog')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="justify-start" asChild>
|
||||
<a href="https://github.com/yyhuni/xingrin/issues" target="_blank" rel="noopener noreferrer">
|
||||
<IconMessageReport className="h-4 w-4 mr-2" />
|
||||
{t('feedback')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="justify-start" asChild>
|
||||
<a href="https://github.com/yyhuni/xingrin#readme" target="_blank" rel="noopener noreferrer">
|
||||
<IconBook className="h-4 w-4 mr-2" />
|
||||
{t('docs')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
© 2025 XingRin · MIT License
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import type * as React from "react"
|
||||
// Import various icons from Tabler Icons library
|
||||
import {
|
||||
IconDashboard, // Dashboard icon
|
||||
IconHelp, // Help icon
|
||||
IconListDetails, // List details icon
|
||||
IconSettings, // Settings icon
|
||||
IconUsers, // Users icon
|
||||
@@ -15,10 +14,10 @@ import {
|
||||
IconServer, // Server icon
|
||||
IconTerminal2, // Terminal icon
|
||||
IconBug, // Vulnerability icon
|
||||
IconMessageReport, // Feedback icon
|
||||
IconSearch, // Search icon
|
||||
IconKey, // API Key icon
|
||||
IconBan, // Blacklist icon
|
||||
IconInfoCircle, // About icon
|
||||
} from "@tabler/icons-react"
|
||||
// Import internationalization hook
|
||||
import { useTranslations } from 'next-intl'
|
||||
@@ -27,8 +26,8 @@ import { Link, usePathname } from '@/i18n/navigation'
|
||||
|
||||
// Import custom navigation components
|
||||
import { NavSystem } from "@/components/nav-system"
|
||||
import { NavSecondary } from "@/components/nav-secondary"
|
||||
import { NavUser } from "@/components/nav-user"
|
||||
import { AboutDialog } from "@/components/about-dialog"
|
||||
// Import sidebar UI components
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -139,20 +138,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
},
|
||||
]
|
||||
|
||||
// Secondary navigation menu items
|
||||
const navSecondary = [
|
||||
{
|
||||
title: t('feedback'),
|
||||
url: "https://github.com/yyhuni/xingrin/issues",
|
||||
icon: IconMessageReport,
|
||||
},
|
||||
{
|
||||
title: t('help'),
|
||||
url: "https://github.com/yyhuni/xingrin",
|
||||
icon: IconHelp,
|
||||
},
|
||||
]
|
||||
|
||||
// System settings related menu items
|
||||
const documents = [
|
||||
{
|
||||
@@ -271,8 +256,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
|
||||
{/* System settings navigation menu */}
|
||||
<NavSystem items={documents} />
|
||||
{/* Secondary navigation menu, using mt-auto to push to bottom */}
|
||||
<NavSecondary items={navSecondary} className="mt-auto" />
|
||||
{/* About system button */}
|
||||
<SidebarGroup className="mt-auto">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<AboutDialog>
|
||||
<SidebarMenuButton>
|
||||
<IconInfoCircle />
|
||||
<span>{t('about')}</span>
|
||||
</SidebarMenuButton>
|
||||
</AboutDialog>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
{/* Sidebar footer */}
|
||||
|
||||
@@ -58,14 +58,6 @@ subdomain_discovery:
|
||||
enabled: true
|
||||
timeout: 600 # 10 minutes (required)
|
||||
|
||||
amass_passive:
|
||||
enabled: true
|
||||
timeout: 600 # 10 minutes (required)
|
||||
|
||||
amass_active:
|
||||
enabled: true
|
||||
timeout: 1800 # 30 minutes (required)
|
||||
|
||||
sublist3r:
|
||||
enabled: true
|
||||
timeout: 900 # 15 minutes (required)
|
||||
|
||||
19
frontend/hooks/use-version.ts
Normal file
19
frontend/hooks/use-version.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { VersionService } from '@/services/version.service'
|
||||
|
||||
export function useVersion() {
|
||||
return useQuery({
|
||||
queryKey: ['version'],
|
||||
queryFn: () => VersionService.getVersion(),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCheckUpdate() {
|
||||
return useQuery({
|
||||
queryKey: ['check-update'],
|
||||
queryFn: () => VersionService.checkUpdate(),
|
||||
enabled: false, // 手动触发
|
||||
staleTime: 5 * 60 * 1000, // 5 分钟缓存
|
||||
})
|
||||
}
|
||||
@@ -325,8 +325,7 @@
|
||||
"notifications": "Notifications",
|
||||
"apiKeys": "API Keys",
|
||||
"globalBlacklist": "Global Blacklist",
|
||||
"help": "Get Help",
|
||||
"feedback": "Feedback"
|
||||
"about": "About"
|
||||
},
|
||||
"search": {
|
||||
"title": "Asset Search",
|
||||
@@ -1486,6 +1485,15 @@
|
||||
"requiredError": "Webhook URL is required when Discord is enabled",
|
||||
"urlInvalid": "Please enter a valid Discord Webhook URL"
|
||||
},
|
||||
"wecom": {
|
||||
"title": "WeCom",
|
||||
"description": "Push notifications to WeCom group bot",
|
||||
"webhookLabel": "Webhook URL",
|
||||
"webhookPlaceholder": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
|
||||
"webhookHelp": "Add a bot in WeCom group and copy the Webhook URL",
|
||||
"requiredError": "Webhook URL is required when WeCom is enabled",
|
||||
"urlInvalid": "Please enter a valid WeCom Webhook URL"
|
||||
},
|
||||
"emailChannel": {
|
||||
"title": "Email",
|
||||
"description": "Receive notifications via email",
|
||||
@@ -2283,5 +2291,21 @@
|
||||
"conflict": "Resource conflict, please check and try again",
|
||||
"unauthorized": "Please login first",
|
||||
"rateLimited": "Too many requests, please try again later"
|
||||
},
|
||||
"about": {
|
||||
"title": "About XingRin",
|
||||
"description": "Attack Surface Management Platform",
|
||||
"currentVersion": "Current Version",
|
||||
"latestVersion": "Latest Version",
|
||||
"checkUpdate": "Check Update",
|
||||
"checking": "Checking...",
|
||||
"checkFailed": "Failed to check update, please try again later",
|
||||
"updateAvailable": "Update Available",
|
||||
"upToDate": "Up to Date",
|
||||
"viewRelease": "View Release",
|
||||
"updateHint": "Run the following command in project root to update:",
|
||||
"changelog": "Changelog",
|
||||
"feedback": "Feedback",
|
||||
"docs": "Documentation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,8 +325,7 @@
|
||||
"notifications": "通知设置",
|
||||
"apiKeys": "API 密钥",
|
||||
"globalBlacklist": "全局黑名单",
|
||||
"help": "获取帮助",
|
||||
"feedback": "反馈建议"
|
||||
"about": "关于系统"
|
||||
},
|
||||
"search": {
|
||||
"title": "资产搜索",
|
||||
@@ -1486,6 +1485,15 @@
|
||||
"requiredError": "启用 Discord 时必须填写 Webhook URL",
|
||||
"urlInvalid": "请输入有效的 Discord Webhook URL"
|
||||
},
|
||||
"wecom": {
|
||||
"title": "企业微信",
|
||||
"description": "将通知推送到企业微信群机器人",
|
||||
"webhookLabel": "Webhook URL",
|
||||
"webhookPlaceholder": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
|
||||
"webhookHelp": "在企业微信群中添加机器人,复制 Webhook 地址",
|
||||
"requiredError": "启用企业微信时必须填写 Webhook URL",
|
||||
"urlInvalid": "请输入有效的企业微信 Webhook URL"
|
||||
},
|
||||
"emailChannel": {
|
||||
"title": "邮件",
|
||||
"description": "通过邮件接收通知",
|
||||
@@ -2283,5 +2291,21 @@
|
||||
"conflict": "资源冲突,请检查后重试",
|
||||
"unauthorized": "请先登录",
|
||||
"rateLimited": "请求过于频繁,请稍后重试"
|
||||
},
|
||||
"about": {
|
||||
"title": "关于 XingRin",
|
||||
"description": "攻击面管理平台",
|
||||
"currentVersion": "当前版本",
|
||||
"latestVersion": "最新版本",
|
||||
"checkUpdate": "检查更新",
|
||||
"checking": "检查中...",
|
||||
"checkFailed": "检查更新失败,请稍后重试",
|
||||
"updateAvailable": "有更新",
|
||||
"upToDate": "已是最新",
|
||||
"viewRelease": "查看发布",
|
||||
"updateHint": "在项目根目录运行以下命令更新:",
|
||||
"changelog": "更新日志",
|
||||
"feedback": "问题反馈",
|
||||
"docs": "使用文档"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ export const mockNotificationSettings: NotificationSettings = {
|
||||
enabled: true,
|
||||
webhookUrl: 'https://discord.com/api/webhooks/1234567890/abcdefghijklmnop',
|
||||
},
|
||||
wecom: {
|
||||
enabled: false,
|
||||
webhookUrl: '',
|
||||
},
|
||||
categories: {
|
||||
scan: true,
|
||||
vulnerability: true,
|
||||
@@ -30,6 +34,7 @@ export function updateMockNotificationSettings(
|
||||
return {
|
||||
message: 'Notification settings updated successfully',
|
||||
discord: mockNotificationSettings.discord,
|
||||
wecom: mockNotificationSettings.wecom,
|
||||
categories: mockNotificationSettings.categories,
|
||||
}
|
||||
}
|
||||
|
||||
14
frontend/services/version.service.ts
Normal file
14
frontend/services/version.service.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { VersionInfo, UpdateCheckResult } from '@/types/version.types'
|
||||
|
||||
export class VersionService {
|
||||
static async getVersion(): Promise<VersionInfo> {
|
||||
const res = await api.get<VersionInfo>('/system/version/')
|
||||
return res.data
|
||||
}
|
||||
|
||||
static async checkUpdate(): Promise<UpdateCheckResult> {
|
||||
const res = await api.get<UpdateCheckResult>('/system/check-update/')
|
||||
return res.data
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ export interface DiscordSettings {
|
||||
webhookUrl: string
|
||||
}
|
||||
|
||||
export interface WeComSettings {
|
||||
enabled: boolean
|
||||
webhookUrl: string
|
||||
}
|
||||
|
||||
/** Notification category - corresponds to backend NotificationCategory */
|
||||
export type NotificationCategory = 'scan' | 'vulnerability' | 'asset' | 'system'
|
||||
|
||||
@@ -16,6 +21,7 @@ export interface NotificationCategories {
|
||||
|
||||
export interface NotificationSettings {
|
||||
discord: DiscordSettings
|
||||
wecom: WeComSettings
|
||||
categories: NotificationCategories
|
||||
}
|
||||
|
||||
@@ -26,5 +32,6 @@ export type UpdateNotificationSettingsRequest = NotificationSettings
|
||||
export interface UpdateNotificationSettingsResponse {
|
||||
message: string
|
||||
discord: DiscordSettings
|
||||
wecom: WeComSettings
|
||||
categories: NotificationCategories
|
||||
}
|
||||
|
||||
13
frontend/types/version.types.ts
Normal file
13
frontend/types/version.types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface VersionInfo {
|
||||
version: string
|
||||
githubRepo: string
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
currentVersion: string
|
||||
latestVersion: string
|
||||
hasUpdate: boolean
|
||||
releaseUrl: string
|
||||
releaseNotes: string | null
|
||||
publishedAt: string | null
|
||||
}
|
||||
108
update.sh
108
update.sh
@@ -21,8 +21,8 @@ cd "$(dirname "$0")"
|
||||
|
||||
# 权限检查
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "\033[0;31m[错误] 请使用 sudo 运行此脚本\033[0m"
|
||||
echo -e " 正确用法: \033[1msudo ./update.sh\033[0m"
|
||||
printf "\033[0;31m✗ 请使用 sudo 运行此脚本\033[0m\n"
|
||||
printf " 正确用法: \033[1msudo ./update.sh\033[0m\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -49,9 +49,17 @@ YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
DIM='\033[2m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 日志函数
|
||||
log_step() { printf "${CYAN}▶${NC} %s\n" "$1"; }
|
||||
log_ok() { printf " ${GREEN}✓${NC} %s\n" "$1"; }
|
||||
log_info() { printf " ${DIM}→${NC} %s\n" "$1"; }
|
||||
log_warn() { printf " ${YELLOW}!${NC} %s\n" "$1"; }
|
||||
log_error() { printf "${RED}✗${NC} %s\n" "$1"; }
|
||||
|
||||
# 合并 .env 新配置项(保留用户已有值)
|
||||
merge_env_config() {
|
||||
local example_file="docker/.env.example"
|
||||
@@ -70,58 +78,68 @@ merge_env_config() {
|
||||
|
||||
if ! grep -q "^${key}=" "$env_file"; then
|
||||
printf '%s\n' "$line" >> "$env_file"
|
||||
echo -e " ${GREEN}+${NC} 新增: $key"
|
||||
log_info "新增配置: $key"
|
||||
((new_keys++))
|
||||
fi
|
||||
done < "$example_file"
|
||||
|
||||
if [ $new_keys -gt 0 ]; then
|
||||
echo -e " ${GREEN}OK${NC} 已添加 $new_keys 个新配置项"
|
||||
log_ok "已添加 $new_keys 个新配置项"
|
||||
else
|
||||
echo -e " ${GREEN}OK${NC} 配置已是最新"
|
||||
log_ok "配置已是最新"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}${BLUE}╔════════════════════════════════════════╗${NC}"
|
||||
# 显示标题
|
||||
printf "\n"
|
||||
printf "${BOLD}${BLUE}┌────────────────────────────────────────┐${NC}\n"
|
||||
if [ "$DEV_MODE" = true ]; then
|
||||
echo -e "${BOLD}${BLUE}║ 开发环境更新(本地构建) ║${NC}"
|
||||
printf "${BOLD}${BLUE}│${NC} ${BOLD}XingRin 系统更新${NC} ${BOLD}${BLUE}│${NC}\n"
|
||||
printf "${BOLD}${BLUE}│${NC} ${DIM}开发模式 · 本地构建${NC} ${BOLD}${BLUE}│${NC}\n"
|
||||
else
|
||||
echo -e "${BOLD}${BLUE}║ 生产环境更新(Docker Hub) ║${NC}"
|
||||
printf "${BOLD}${BLUE}│${NC} ${BOLD}XingRin 系统更新${NC} ${BOLD}${BLUE}│${NC}\n"
|
||||
printf "${BOLD}${BLUE}│${NC} ${DIM}生产模式 · Docker Hub${NC} ${BOLD}${BLUE}│${NC}\n"
|
||||
fi
|
||||
echo -e "${BOLD}${BLUE}╚════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
printf "${BOLD}${BLUE}└────────────────────────────────────────┘${NC}\n"
|
||||
printf "\n"
|
||||
|
||||
# 测试性功能警告
|
||||
echo -e "${BOLD}${YELLOW}[!] 警告:此功能为测试性功能,可能会导致升级失败${NC}"
|
||||
echo -e "${YELLOW} 建议运行 ./uninstall.sh 后重新执行 ./install.sh 进行全新安装${NC}"
|
||||
echo ""
|
||||
echo -n -e "${YELLOW}是否继续更新?(y/N) ${NC}"
|
||||
# 警告提示
|
||||
printf "${YELLOW}┌─ 注意事项 ─────────────────────────────┐${NC}\n"
|
||||
printf "${YELLOW}│${NC} • 此功能为测试性功能,可能导致升级失败 ${YELLOW}│${NC}\n"
|
||||
printf "${YELLOW}│${NC} • 升级会覆盖所有默认引擎配置 ${YELLOW}│${NC}\n"
|
||||
printf "${YELLOW}│${NC} • 自定义配置请先备份或创建新引擎 ${YELLOW}│${NC}\n"
|
||||
printf "${YELLOW}│${NC} • 推荐:卸载后重新安装以获得最佳体验 ${YELLOW}│${NC}\n"
|
||||
printf "${YELLOW}└────────────────────────────────────────┘${NC}\n"
|
||||
printf "\n"
|
||||
|
||||
printf "${YELLOW}是否继续更新?${NC} [y/N] "
|
||||
read -r ans_continue
|
||||
ans_continue=${ans_continue:-N}
|
||||
|
||||
if [[ ! $ans_continue =~ ^[Yy]$ ]]; then
|
||||
echo -e "${CYAN}已取消更新。${NC}"
|
||||
printf "\n${DIM}已取消更新${NC}\n"
|
||||
exit 0
|
||||
fi
|
||||
echo ""
|
||||
printf "\n"
|
||||
|
||||
# Step 1: 停止服务
|
||||
echo -e "${CYAN}[1/5]${NC} 停止服务..."
|
||||
./stop.sh 2>&1 | sed 's/^/ /'
|
||||
log_step "停止服务..."
|
||||
./stop.sh 2>&1 | sed 's/^/ /'
|
||||
log_ok "服务已停止"
|
||||
|
||||
# Step 2: 拉取代码
|
||||
echo ""
|
||||
echo -e "${CYAN}[2/5]${NC} 拉取代码..."
|
||||
git pull --rebase 2>&1 | sed 's/^/ /'
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}[错误]${NC} git pull 失败,请手动解决冲突后重试"
|
||||
printf "\n"
|
||||
log_step "拉取最新代码..."
|
||||
if git pull --rebase 2>&1 | sed 's/^/ /'; then
|
||||
log_ok "代码已更新"
|
||||
else
|
||||
log_error "git pull 失败,请手动解决冲突后重试"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 3: 检查配置更新 + 版本同步
|
||||
echo ""
|
||||
echo -e "${CYAN}[3/5]${NC} 检查配置更新..."
|
||||
printf "\n"
|
||||
log_step "同步配置..."
|
||||
merge_env_config
|
||||
|
||||
# 版本同步:从 VERSION 文件更新 IMAGE_TAG
|
||||
@@ -130,21 +148,20 @@ if [ -f "VERSION" ]; then
|
||||
if [ -n "$NEW_VERSION" ]; then
|
||||
if grep -q "^IMAGE_TAG=" "docker/.env"; then
|
||||
sed_inplace "s/^IMAGE_TAG=.*/IMAGE_TAG=$NEW_VERSION/" "docker/.env"
|
||||
echo -e " ${GREEN}+${NC} 版本同步: IMAGE_TAG=$NEW_VERSION"
|
||||
else
|
||||
printf '%s\n' "IMAGE_TAG=$NEW_VERSION" >> "docker/.env"
|
||||
echo -e " ${GREEN}+${NC} 新增版本: IMAGE_TAG=$NEW_VERSION"
|
||||
fi
|
||||
log_ok "版本同步: $NEW_VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 4: 构建/拉取镜像
|
||||
echo ""
|
||||
echo -e "${CYAN}[4/5]${NC} 更新镜像..."
|
||||
printf "\n"
|
||||
log_step "更新镜像..."
|
||||
|
||||
if [ "$DEV_MODE" = true ]; then
|
||||
# 开发模式:本地构建所有镜像(包括 Worker)
|
||||
echo -e " 构建 Worker 镜像..."
|
||||
log_info "构建 Worker 镜像..."
|
||||
|
||||
# 读取 IMAGE_TAG
|
||||
IMAGE_TAG=$(grep "^IMAGE_TAG=" "docker/.env" | cut -d'=' -f2)
|
||||
@@ -153,24 +170,23 @@ if [ "$DEV_MODE" = true ]; then
|
||||
fi
|
||||
|
||||
# 构建 Worker 镜像(Worker 是临时容器,不在 compose 中,需要单独构建)
|
||||
docker build -t docker-worker -f docker/worker/Dockerfile . 2>&1 | sed 's/^/ /'
|
||||
docker tag docker-worker docker-worker:${IMAGE_TAG} 2>&1 | sed 's/^/ /'
|
||||
echo -e " ${GREEN}OK${NC} Worker 镜像已构建: docker-worker:${IMAGE_TAG}"
|
||||
docker build -t docker-worker -f docker/worker/Dockerfile . 2>&1 | sed 's/^/ /'
|
||||
docker tag docker-worker docker-worker:${IMAGE_TAG} 2>&1 | sed 's/^/ /'
|
||||
log_ok "Worker 镜像: docker-worker:${IMAGE_TAG}"
|
||||
|
||||
# 其他服务镜像由 start.sh --dev 构建
|
||||
echo -e " 其他服务镜像将在启动时构建..."
|
||||
log_info "其他服务镜像将在启动时构建"
|
||||
else
|
||||
# 生产模式:镜像由 start.sh 拉取
|
||||
echo -e " 镜像将在启动时从 Docker Hub 拉取..."
|
||||
log_info "镜像将在启动时从 Docker Hub 拉取"
|
||||
fi
|
||||
|
||||
# Step 5: 启动服务
|
||||
echo ""
|
||||
echo -e "${CYAN}[5/5]${NC} 启动服务..."
|
||||
printf "\n"
|
||||
log_step "启动服务..."
|
||||
./start.sh "$@"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}${GREEN}════════════════════════════════════════${NC}"
|
||||
echo -e "${BOLD}${GREEN} 更新完成!${NC}"
|
||||
echo -e "${BOLD}${GREEN}════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
# 完成提示
|
||||
printf "\n"
|
||||
printf "${GREEN}┌────────────────────────────────────────┐${NC}\n"
|
||||
printf "${GREEN}│${NC} ${BOLD}${GREEN}✓${NC} ${BOLD}更新完成${NC} ${GREEN}│${NC}\n"
|
||||
printf "${GREEN}└────────────────────────────────────────┘${NC}\n"
|
||||
printf "\n"
|
||||
|
||||
Reference in New Issue
Block a user