Compare commits

...

61 Commits

Author SHA1 Message Date
yyhuni
69a598e789 更新:显示版本号 2025-12-21 09:05:25 +08:00
yyhuni
54017d0334 更新:描述 2025-12-21 09:01:20 +08:00
github-actions[bot]
8ac97b4451 chore: bump version to v1.0.25 2025-12-21 00:59:27 +00:00
yyhuni
0a4f1d45be 更新:readme 2025-12-21 08:52:33 +08:00
yyhuni
bce310a4b0 更新:描述 2025-12-21 08:46:47 +08:00
yyhuni
8502daf8a0 更新:readme 2025-12-21 08:42:00 +08:00
yyhuni
d0066dd9fc 更新:IP页面ui 2025-12-21 08:27:49 +08:00
yyhuni
3407a98cac 更新:ui URL列 2025-12-21 08:17:46 +08:00
yyhuni
3d189431fc fix: 清理字符串中的 NUL 字符和其他不可打印字符
PostgreSQL 不允许字符串字段包含 NUL (0x00) 字符
2025-12-21 08:12:10 +08:00
github-actions[bot]
1cbb6350c4 chore: bump version to v1.0.24 2025-12-19 13:29:15 +00:00
yyhuni
20a22f98d0 更新配置说明 2025-12-19 21:19:58 +08:00
yyhuni
a96ab79891 更新文档 2025-12-19 21:12:28 +08:00
github-actions[bot]
3744a724be chore: bump version to v1.0.23 2025-12-19 12:17:31 +00:00
yyhuni
f63e40fbba 优化:agent自动更新逻辑 2025-12-19 20:07:55 +08:00
yyhuni
54573e210a fix: agent更新逻辑 2025-12-19 20:00:36 +08:00
github-actions[bot]
6179dd2ed3 chore: bump version to v1.0.22 2025-12-19 11:56:56 +00:00
github-actions[bot]
34ac706fbc chore: bump version to v1.0.21 2025-12-19 11:49:51 +00:00
yyhuni
3ba1ba427e fix: agent自动更新逻辑 2025-12-19 19:48:01 +08:00
yyhuni
6019555729 fix:ssl问题 2025-12-19 19:41:12 +08:00
github-actions[bot]
750f52c515 chore: bump version to v1.0.20 2025-12-19 11:28:27 +00:00
yyhuni
bb5ce66a31 fix:agent容器版本号匹配 2025-12-19 19:20:15 +08:00
github-actions[bot]
ac958571a5 chore: bump version to v1.0.19 2025-12-19 11:12:14 +00:00
yyhuni
bcb321f883 Merge branch 'main' of https://github.com/yyhuni/xingrin 2025-12-19 19:03:39 +08:00
yyhuni
fd3cdf8033 fix:远程worker 8888端口问题 2025-12-19 19:02:43 +08:00
github-actions[bot]
f3f9718df2 chore: bump version to v1.0.18 2025-12-19 10:47:10 +00:00
yyhuni
984c34dbca 优化:取消暴漏8888端口 2025-12-19 18:37:05 +08:00
yyhuni
e9dcbf510d 更新readme 2025-12-19 16:19:11 +08:00
yyhuni
65deb8c5d0 更新文档 2025-12-19 16:15:57 +08:00
yyhuni
5a93ad878c 更新架构图文档 2025-12-19 16:05:32 +08:00
github-actions[bot]
51f25d0976 chore: bump version to v1.0.17 2025-12-19 04:50:17 +00:00
yyhuni
fe1579e7fb 优化 :负载逻辑,高负载时先等待,给系统喘息时间 2025-12-19 12:42:15 +08:00
yyhuni
ef117d2245 fix:交换分区开启命令 2025-12-19 12:33:48 +08:00
yyhuni
39cea5a918 增加:脚本一键开启交换分区 2025-12-19 12:30:02 +08:00
github-actions[bot]
0d477ce269 chore: bump version to v1.0.16 2025-12-19 04:23:00 +00:00
yyhuni
1bb6e90c3d fix:github action 2025-12-19 12:14:40 +08:00
yyhuni
9004c77031 新增:负载感知通知 2025-12-19 12:11:20 +08:00
yyhuni
71de0b4b1b FIX: 前端显示 2025-12-19 11:32:02 +08:00
github-actions[bot]
1ef1f9709e chore: bump version to v1.0.14 2025-12-19 03:12:46 +00:00
yyhuni
3323bd2a4f fix:前端构建问题 2025-12-19 10:54:23 +08:00
yyhuni
df602dd1ae 优化:仓库获取失败的提示 2025-12-19 10:40:29 +08:00
yyhuni
372bab5267 fix:重构本地worker判断逻辑,修复数据库ip连接问题 2025-12-19 10:36:09 +08:00
yyhuni
bed80e4ba7 优化:卸载脚本兼容性,防止清理数据库不干净 2025-12-19 10:20:04 +08:00
github-actions[bot]
3b014bd04c chore: bump version to v1.0.13 2025-12-19 00:30:12 +00:00
yyhuni
5e60911cb3 fix:构建问题 2025-12-19 08:12:40 +08:00
yyhuni
5de7ea9dbc 优化:利用docker hub加速构建 2025-12-18 22:56:58 +08:00
github-actions[bot]
971641cdeb chore: bump version to v1.0.10 2025-12-18 14:52:35 +00:00
poem
e5a74faf9f 删除:旧文档 2025-12-18 22:35:39 +08:00
poem
e9a58e89aa 更新:前端表格字段命名,后端注释更新 2025-12-18 22:31:55 +08:00
rongxinrou
3d9d520dc7 新增dev下进行worker构建,并使用本地构建的worker镜像启动容器 2025-12-18 21:54:54 +08:00
yyhuni
8d814b5864 fix: 日志逻辑 2025-12-18 20:39:53 +08:00
rongxinrou
c16b7afabe 增加日志 2025-12-18 20:30:25 +08:00
rongxinrou
fa55167989 修复端口扫描IP时的问题 2025-12-18 20:16:21 +08:00
yyhuni
55a2762c71 fix: 证书兼容性 2025-12-18 18:53:28 +08:00
github-actions[bot]
5532f1e63a chore: bump version to v1.0.9 2025-12-18 10:51:53 +00:00
yyhuni
948568e950 更新注释 2025-12-18 18:42:37 +08:00
yyhuni
873b6893f1 fix version 2025-12-18 18:32:20 +08:00
yyhuni
dbb30f7c78 chore: bump version to v1.0.9 2025-12-18 18:29:35 +08:00
yyhuni
38eced3814 fix: Prefect 本地模式配置:禁用 API server 和事件系统 2025-12-18 18:28:19 +08:00
yyhuni
68fc7cee3b fix: 兼容旧版本证书安装,兼容旧版本docker compose 2025-12-18 17:44:02 +08:00
yyhuni
6e23824a45 todo:待接入add_command_to_scan 2025-12-17 21:40:07 +08:00
github-actions[bot]
a88cceb4f4 chore: bump version to v1.0.8 2025-12-17 11:09:39 +00:00
64 changed files with 1548 additions and 894 deletions

View File

@@ -16,7 +16,7 @@ env:
IMAGE_PREFIX: yyhuni
permissions:
contents: write # 允许修改仓库内容
contents: write
jobs:
build:
@@ -27,18 +27,23 @@ jobs:
- image: xingrin-server
dockerfile: docker/server/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
- image: xingrin-frontend
dockerfile: docker/frontend/Dockerfile
context: .
platforms: linux/amd64 # ARM64 构建时 Next.js 在 QEMU 下会崩溃
- image: xingrin-worker
dockerfile: docker/worker/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
- image: xingrin-nginx
dockerfile: docker/nginx/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
- image: xingrin-agent
dockerfile: docker/agent/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
steps:
- name: Checkout
@@ -48,7 +53,6 @@ jobs:
run: |
echo "=== Before cleanup ==="
df -h
# 删除不需要的大型软件包
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
@@ -95,18 +99,22 @@ jobs:
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.platforms }}
push: true
tags: |
${{ env.IMAGE_PREFIX }}/${{ matrix.image }}:${{ steps.version.outputs.VERSION }}
${{ steps.version.outputs.IS_RELEASE == 'true' && format('{0}/{1}:latest', env.IMAGE_PREFIX, matrix.image) || '' }}
build-args: |
IMAGE_TAG=${{ steps.version.outputs.VERSION }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
sbom: false
# 所有镜像构建成功后,更新 VERSION 文件
update-version:
runs-on: ubuntu-latest
needs: build # 等待所有 build job 完成
needs: build
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout

View File

@@ -1,12 +0,0 @@
---
trigger: always_on
---
1.后端网页应该是 8888 端口
3.前端所有路由加上末尾斜杠,以匹配 django 的 DRF 规则
4.网页测试可以用 curl
8.所有前端 api 接口都应该写在@services 中,所有 type 类型都应该写在@types
10.前端的加载等逻辑用 React Query来实现自动管理
17.所有业务操作的 toast 都放在 hook 中
23.前端非必要不要采用window.location.href去跳转而是用Next.js 客户端路由
24.ui相关的都去调用mcp来看看有没有通用组件美观的组件来实现

View File

@@ -1,85 +0,0 @@
---
trigger: manual
description: 进行代码审查的时候,必须调用这个规则
---
### **0. 逻辑正确性 & Bug 排查** *(最高优先级,必须手动推演)*
**目标**:不依赖测试,主动发现“代码能跑但结果错”的逻辑错误。
1. **手动推演关键路径**
- 选 2~3 个典型输入(含边界),**在脑中或纸上一步步推演代码执行流程**。
- 输出是否符合预期?每一步变量变化是否正确?
2. **常见逻辑 bug 检查**
- **off-by-one**:循环、数组索引、分页
- **条件逻辑错误**`and`/`or` 优先级、短路求值误用
- **状态混乱**:变量未初始化、被意外覆盖
- **算法偏差**:排序、搜索、二分查找的中点处理
- **浮点精度**:是否误用 `==` 比较浮点数?
3. **控制流审查**
- 所有 `if/else` 分支是否都覆盖?有无“不可达代码”?
- `switch`/`match` 是否有 `default`?是否漏 case
- 异常路径会返回什么?是否遗漏 `finally` 清理?
4. **业务逻辑一致性**
- 是否符合**业务规则**?(如“订单总额 = 商品价 × 数量 + 运费 - 折扣”)
- 是否遗漏隐含约束?(如“用户只能评价已完成的订单”)
### **一、功能性 & 正确性** *(阻塞性问题必须修复)*
1. **需求符合度**是否100%覆盖需求?遗漏/多余功能点?
2. **边界条件**
- 输入:`null`、空、极值、非法格式
- 集合空、单元素、超大如10⁶
- 循环终止条件、off-by-one
3. **错误处理**
- 异常捕获全面?失败路径有降级?
- 错误信息清晰?不泄露栈迹?
4. **并发安全**
- 竞态/死锁风险?共享资源是否同步?
- 使用了`volatile`/`synchronized`/`Lock`/`atomic`
5. **单元测试**
- 覆盖率 ≥80%?包含正向/边界/异常用例?
- 测试独立?无外部依赖?
### **二、代码质量与可读性**
1. **命名**:见名知意?遵循规范?
2. **函数设计**
- **单一职责**?参数 ≤4建议长度 <50行视语言调整
- 可提取为工具函数?
3. **结构与复杂度**
- 无重复代码?圈复杂度 <10
- 嵌套 ≤3层使用卫语句提前返回
4. **注释**:解释**为什么**而非**是什么**?复杂逻辑必注释
5. **风格一致**:通过`Prettier`/`ESLint`/`Spotless`自动格式化
### **三、架构与设计**
1. **SOLID**:是否符合单一职责、开闭、依赖倒置?
2. **依赖**:是否依赖接口而非实现?无循环依赖?
3. **可测试性**:是否支持依赖注入?避免`new`硬编码
4. **扩展性**:新增功能是否只需改一处?
### **四、性能优化**
- **N+1查询**循环内IO/日志/分配?
- 算法复杂度合理如O(n²)是否可优化)
- 内存:无泄漏?大对象及时释放?缓存有失效?
### **五、其他**
1. **可维护性**:日志带上下文?修改后更干净?
2. **兼容性**API/数据库变更是否向后兼容?
3. **依赖管理**:新库必要?许可证合规?
---
### **审查最佳实践**
- **小批次审查**≤200行/次
- **语气建议**`“建议将函数拆分以提升可读性”` 而非 `“这个函数太长了”`
- **自动化先行**:风格/空指针/安全扫描 → CI工具
- **重点分级**
- 🛑 **阻塞**:功能错、安全漏洞
- ⚠️ **必须改**:设计缺陷、性能瓶颈
- 💡 **建议**:风格、命名、可读性

View File

@@ -1,195 +0,0 @@
---
trigger: always_on
---
## 标准分层架构调用顺序
按照 **DDD领域驱动设计和清洁架构**原则,调用顺序应该是:
```
HTTP请求 → Views → Tasks → Services → Repositories → Models
```
---
### 📊 完整的调用链路图
```
┌─────────────────────────────────────────────────────────────┐
│ HTTP Request (前端) │
└────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Views (HTTP 层) │
│ - 参数验证 │
│ - 权限检查 │
│ - 调用 Tasks/Services │
│ - 返回 HTTP 响应 │
└────────────────────────┬────────────────────────────────────┘
┌────────────────┴────────────────┐
↓ (异步) ↓ (同步)
┌──────────────────┐ ┌──────────────────┐
│ Tasks (任务层) │ │ Services (业务层)│
│ - 异步执行 │ │ - 业务逻辑 │
│ - 后台作业 │───────>│ - 事务管理 │
│ - 通知发送 │ │ - 数据验证 │
└──────────────────┘ └────────┬─────────┘
┌──────────────────────┐
│ Repositories (存储层) │
│ - 数据访问 │
│ - 查询封装 │
│ - 批量操作 │
└────────┬─────────────┘
┌──────────────────────┐
│ Models (模型层) │
│ - ORM 定义 │
│ - 数据结构 │
│ - 关系映射 │
└──────────────────────┘
```
---
### 🔄 具体调用示例
### **场景 1同步删除Views → Services → Repositories → Models**
```python
# 1. Views 层 (views.py)
def some_sync_delete(self, request):
# 参数验证
target_ids = request.data.get('ids')
# 调用 Service 层
service = TargetService()
result = service.bulk_delete_targets(target_ids)
# 返回响应
return Response({'message': 'deleted'})
# 2. Services 层 (services/target_service.py)
class TargetService:
def bulk_delete_targets(self, target_ids):
# 业务逻辑验证
logger.info("准备删除...")
# 调用 Repository 层
deleted_count = self.repo.bulk_delete_by_ids(target_ids)
# 返回结果
return deleted_count
# 3. Repositories 层 (repositories/django_target_repository.py)
class DjangoTargetRepository:
def bulk_delete_by_ids(self, target_ids):
# 数据访问操作
return Target.objects.filter(id__in=target_ids).delete()
# 4. Models 层 (models.py)
class Target(models.Model):
# ORM 定义
name = models.CharField(...)
```
---
### **场景 2异步删除Views → Tasks → Services → Repositories → Models**
```python
# 1. Views 层 (views.py)
def destroy(self, request, *args, **kwargs):
target = self.get_object()
# 调用 Tasks 层(异步)
async_bulk_delete_targets([target.id], [target.name])
# 立即返回 202
return Response(status=202)
# 2. Tasks 层 (tasks/target_tasks.py)
def async_bulk_delete_targets(target_ids, target_names):
def _delete():
# 发送通知
create_notification("删除中...")
# 调用 Service 层
service = TargetService()
result = service.bulk_delete_targets(target_ids)
# 发送完成通知
create_notification("删除成功")
# 后台线程执行
threading.Thread(target=_delete).start()
# 3. Services 层 (services/target_service.py)
class TargetService:
def bulk_delete_targets(self, target_ids):
# 业务逻辑
return self.repo.bulk_delete_by_ids(target_ids)
# 4. Repositories 层 (repositories/django_target_repository.py)
class DjangoTargetRepository:
def bulk_delete_by_ids(self, target_ids):
# 数据访问
return Target.objects.filter(id__in=target_ids).delete()
# 5. Models 层 (models.py)
class Target(models.Model):
# ORM 定义
...
```
---
### 📋 各层职责清单
| 层级 | 职责 | 不应该做 |
| --- | --- | --- |
| **Views** | HTTP 请求处理、参数验证、权限检查 | ❌ 直接访问 Models<br>❌ 业务逻辑 |
| **Tasks** | 异步执行、后台作业、通知发送 | ❌ 直接访问 Models<br>❌ HTTP 响应 |
| **Services** | 业务逻辑、事务管理、数据验证 | ❌ 直接写 SQL<br>❌ HTTP 相关 |
| **Repositories** | 数据访问、查询封装、批量操作 | ❌ 业务逻辑<br>❌ 通知发送 |
| **Models** | ORM 定义、数据结构、关系映射 | ❌ 业务逻辑<br>❌ 复杂查询 |
---
### ✅ 最佳实践原则
1. **单向依赖**:只能向下调用,不能向上调用
```
Views → Tasks → Services → Repositories → Models
(上层) (下层)
```
2. **层级隔离**:相邻层交互,禁止跨层
- ✅ Views → Services
- ✅ Tasks → Services
- ✅ Services → Repositories
- ❌ Views → Repositories跨层
- ❌ Tasks → Models跨层
3. **依赖注入**:通过构造函数注入依赖
```python
class TargetService:
def __init__(self):
self.repo = DjangoTargetRepository() # 注入
```
4. **接口抽象**:使用 Protocol 定义接口
```python
class TargetRepository(Protocol):
def bulk_delete_by_ids(self, ids): ...
```

169
README.md
View File

@@ -1,10 +1,30 @@
<h1 align="center">Xingrin - 星环</h1>
<h1 align="center">XingRin - 星环</h1>
<p align="center">
<b>一款现代化的企业级漏洞扫描与资产管理平台</b><br>
提供自动化安全检测、资产发现、漏洞管理等功能
<b>🛡️ 攻击面管理平台 (ASM) | 自动化资产发现与漏洞扫描系统</b>
</p>
<p align="center">
<a href="https://github.com/yyhuni/xingrin/stargazers"><img src="https://img.shields.io/github/stars/yyhuni/xingrin?style=flat-square&logo=github" alt="GitHub stars"></a>
<a href="https://github.com/yyhuni/xingrin/network/members"><img src="https://img.shields.io/github/forks/yyhuni/xingrin?style=flat-square&logo=github" alt="GitHub forks"></a>
<a href="https://github.com/yyhuni/xingrin/issues"><img src="https://img.shields.io/github/issues/yyhuni/xingrin?style=flat-square&logo=github" alt="GitHub issues"></a>
<a href="https://github.com/yyhuni/xingrin/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-PolyForm%20NC-blue?style=flat-square" alt="License"></a>
</p>
<p align="center">
<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>
</p>
---
<p align="center">
<b>🌗 明暗模式切换</b>
</p>
@@ -31,6 +51,7 @@
- [🔄 版本管理](./docs/version-management.md) - Git Tag 驱动的自动化版本管理系统
- [📦 Nuclei 模板架构](./docs/nuclei-template-architecture.md) - 模板仓库的存储与同步
- [📖 字典文件架构](./docs/wordlist-architecture.md) - 字典文件的存储与同步
- [🔍 扫描流程架构](./docs/scan-flow-architecture.md) - 完整扫描流程与工具编排
---
@@ -48,6 +69,54 @@
- **自定义流程** - YAML 配置扫描流程,灵活编排
- **定时扫描** - Cron 表达式配置,自动化周期扫描
#### 扫描流程架构
完整的扫描流程包括子域名发现、端口扫描、站点发现、URL 收集、目录扫描、漏洞扫描等阶段
```mermaid
flowchart LR
START["开始扫描"]
subgraph STAGE1["阶段 1: 资产发现"]
direction TB
SUB["子域名发现<br/>subfinder, amass, puredns"]
PORT["端口扫描<br/>naabu"]
SITE["站点识别<br/>httpx"]
SUB --> PORT --> SITE
end
subgraph STAGE2["阶段 2: 深度分析"]
direction TB
URL["URL 收集<br/>waymore, katana"]
DIR["目录扫描<br/>ffuf"]
end
subgraph STAGE3["阶段 3: 漏洞检测"]
VULN["漏洞扫描<br/>nuclei, dalfox"]
end
FINISH["扫描完成"]
START --> STAGE1
SITE --> STAGE2
STAGE2 --> STAGE3
STAGE3 --> FINISH
style START fill:#34495e,stroke:#2c3e50,stroke-width:2px,color:#fff
style FINISH fill:#27ae60,stroke:#229954,stroke-width:2px,color:#fff
style STAGE1 fill:#3498db,stroke:#2980b9,stroke-width:2px,color:#fff
style STAGE2 fill:#9b59b6,stroke:#8e44ad,stroke-width:2px,color:#fff
style STAGE3 fill:#e67e22,stroke:#d35400,stroke-width:2px,color:#fff
style SUB fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
style PORT fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
style SITE fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
style URL fill:#bb8fce,stroke:#9b59b6,stroke-width:1px,color:#fff
style DIR fill:#bb8fce,stroke:#9b59b6,stroke-width:1px,color:#fff
style VULN fill:#f0b27a,stroke:#e67e22,stroke-width:1px,color:#fff
```
详细说明请查看 [扫描流程架构文档](./docs/scan-flow-architecture.md)
### 🖥️ 分布式架构
- **多节点扫描** - 支持部署多个 Worker 节点,横向扩展扫描能力
- **本地节点** - 零配置,安装即自动注册本地 Docker Worker
@@ -56,34 +125,41 @@
- **节点监控** - 实时心跳检测CPU/内存/磁盘状态监控
- **断线重连** - 节点离线自动检测,恢复后自动重新接入
```
┌─────────────────────────────────────────────────────────────────┐
│ 主服务器 (Master) │
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Next.js │ │ Django │ │ Postgres│ │ Redis │
│ 前端 │ │ 后端 │ │ 数据库 │ │ 缓存 │ │
│ └─────────┘ └────┬────┘ └─────────┘ └─────────┘
│ │
┌─────┴─────┐ │
│ 任务调度器 │ │
│ Scheduler │ │
└─────┬─────┘ │
└────────────────────┼────────────────────────────────────────────┘
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
Worker 1 │ │ Worker 2 │ │ Worker N │
│ (本地) │ │ (远程) │ │ (远程) │
├───────────┤ ├───────────┤ ├───────────┤
│ • Nuclei │ │ • Nuclei │ │ • Nuclei │
│ • httpx │ │ • httpx │ │ • httpx │
│ • naabu │ │ • naabu │ │ • naabu │
│ • ... │ │ • ... │ │ • ... │
├───────────┤ ├───────────┤ ├───────────┤
心跳上报 │ │ 心跳上报 │ │ 心跳上报 │
└───────────┘ └───────────┘ └───────────┘
```mermaid
flowchart TB
subgraph MASTER["主服务器 (Master Server)"]
direction TB
REDIS["Redis 负载缓存"]
subgraph SCHEDULER["任务调度器 (Task Distributor)"]
direction TB
SUBMIT["接收扫描任务"]
SELECT["负载感知选择"]
DISPATCH["智能分发"]
SUBMIT --> SELECT
SELECT --> DISPATCH
end
REDIS -.负载数据.-> SELECT
end
subgraph WORKERS["Worker 节点集群"]
direction TB
W1["Worker 1 (本地)<br/>CPU: 45% | MEM: 60%"]
W2["Worker 2 (远程)<br/>CPU: 30% | MEM: 40%"]
W3["Worker N (远程)<br/>CPU: 90% | MEM: 85%"]
end
DISPATCH -->|任务分发| W1
DISPATCH -->|任务分发| W2
DISPATCH -->|高负载跳过| W3
W1 -.心跳上报.-> REDIS
W2 -.心跳上报.-> REDIS
W3 -.心跳上报.-> REDIS
```
### 📊 可视化界面
@@ -100,27 +176,12 @@
- **数据库**: PostgreSQL + Redis
- **部署**: Docker + Nginx
### 🔧 内置扫描工具
| 类别 | 工具 |
|------|------|
| 子域名爆破 | puredns, massdns, dnsgen |
| 被动发现 | subfinder, amass, assetfinder, Sublist3r |
| 端口扫描 | naabu |
| 站点发现 | httpx |
| 目录扫描 | ffuf |
| 爬虫 | katana |
| 被动URL收集 | waymore, uro |
| 漏洞扫描 | nuclei, dalfox |
---
## 📦 快速开始
### 环境要求
- **操作系统**: Ubuntu 20.04+ / Debian 11+ (推荐)
- **硬件**: 2核 4G 内存起步,10GB+ 磁盘空间
- **硬件**: 2核 4G 内存起步,20GB+ 磁盘空间
### 一键安装
@@ -131,14 +192,11 @@ cd xingrin
# 安装并启动(生产模式)
sudo ./install.sh
# 开发模式
sudo ./install.sh --dev
```
### 访问服务
- **Web 界面**: `https://localhost` `http://localhost`
- **Web 界面**: `https://localhost`
### 常用命令
@@ -158,9 +216,6 @@ sudo ./uninstall.sh
# 更新
sudo ./update.sh
```
## 日志
- 项目日志:/opt/xingrin/logs 下存储了这个项目的运行日志信息error文件存储了错误相关信息xingrin.log存储了包括错误在内的所有项目日志
- 工具调用日志:/opt/xingrin/results 下存储了工具的运行结果日志比如naabuhttpx等的结果调用日志
## 🤝 反馈与贡献
@@ -192,6 +247,12 @@ sudo ./update.sh
- 遵守所在地区的法律法规
- 承担因滥用产生的一切后果
## 🌟 Star History
如果这个项目对你有帮助,请给一个 ⭐ Star 支持一下!
[![Star History Chart](https://api.star-history.com/svg?repos=yyhuni/xingrin&type=Date)](https://star-history.com/#yyhuni/xingrin&Date)
## 📄 许可证
本项目采用 [PolyForm Noncommercial License 1.0.0](LICENSE) 许可证。

View File

@@ -1 +1 @@
v1.0.7
v1.0.25

View File

@@ -14,6 +14,10 @@ import os
import sys
import requests
import logging
import urllib3
# 禁用自签名证书的 SSL 警告(远程 Worker 场景)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = logging.getLogger(__name__)
@@ -30,17 +34,27 @@ def fetch_config_and_setup_django():
print("[ERROR] 缺少 SERVER_URL 环境变量", file=sys.stderr)
sys.exit(1)
config_url = f"{server_url}/api/workers/config/"
# 通过环境变量声明 Worker 身份(本地/远程)
is_local = os.environ.get("IS_LOCAL", "false").lower() == "true"
config_url = f"{server_url}/api/workers/config/?is_local={str(is_local).lower()}"
print(f"[CONFIG] 正在从配置中心获取配置: {config_url}")
print(f"[CONFIG] IS_LOCAL={is_local}")
try:
resp = requests.get(config_url, timeout=10)
# verify=False: 远程 Worker 通过 HTTPS 访问时可能使用自签名证书
resp = requests.get(config_url, timeout=10, verify=False)
resp.raise_for_status()
config = resp.json()
# 数据库配置(必需)
os.environ.setdefault("DB_HOST", config['db']['host'])
os.environ.setdefault("DB_PORT", config['db']['port'])
os.environ.setdefault("DB_NAME", config['db']['name'])
os.environ.setdefault("DB_USER", config['db']['user'])
db_host = config['db']['host']
db_port = config['db']['port']
db_name = config['db']['name']
db_user = config['db']['user']
os.environ.setdefault("DB_HOST", db_host)
os.environ.setdefault("DB_PORT", db_port)
os.environ.setdefault("DB_NAME", db_name)
os.environ.setdefault("DB_USER", db_user)
os.environ.setdefault("DB_PASSWORD", config['db']['password'])
# Redis 配置
@@ -52,7 +66,12 @@ def fetch_config_and_setup_django():
os.environ.setdefault("ENABLE_COMMAND_LOGGING", str(config['logging']['enableCommandLogging']).lower())
os.environ.setdefault("DEBUG", str(config['debug']))
print(f"[CONFIG] 从配置中心获取配置成功: {config_url}")
print(f"[CONFIG] ✓ 配置获取成功")
print(f"[CONFIG] DB_HOST: {db_host}")
print(f"[CONFIG] DB_PORT: {db_port}")
print(f"[CONFIG] DB_NAME: {db_name}")
print(f"[CONFIG] DB_USER: {db_user}")
print(f"[CONFIG] REDIS_URL: {config['redisUrl']}")
except Exception as e:
print(f"[ERROR] 获取配置失败: {config_url} - {e}", file=sys.stderr)

View File

@@ -27,3 +27,10 @@ vulnerabilities_saved = Signal()
# - worker_name: str Worker 名称
# - message: str 失败原因
worker_delete_failed = Signal()
# 所有 Worker 高负载信号
# 参数:
# - worker_name: str 被选中的 Worker 名称
# - cpu: float CPU 使用率
# - mem: float 内存使用率
all_workers_high_load = Signal()

View File

@@ -241,8 +241,9 @@ class WorkerDeployConsumer(AsyncWebsocketConsumer):
}))
return
django_host = f"{public_host}:{server_port}" # Django / 心跳上报使用
heartbeat_api_url = f"http://{django_host}" # 基础 URLagent 会加 /api/...
# 远程 Worker 通过 nginx HTTPS 访问nginx 反代到后端 8888
# 使用 https://{PUBLIC_HOST} 而不是直连 8888 端口
heartbeat_api_url = f"https://{public_host}" # 基础 URLagent 会加 /api/...
session_name = f'xingrin_deploy_{self.worker_id}'
remote_script_path = '/tmp/xingrin_deploy.sh'

View File

@@ -10,6 +10,8 @@ class WorkerNode(models.Model):
('deploying', '部署中'),
('online', '在线'),
('offline', '离线'),
('updating', '更新中'),
('outdated', '版本过低'),
]
name = models.CharField(max_length=100, help_text='节点名称')

View File

@@ -198,9 +198,27 @@ class NucleiTemplateRepoService:
# 判断是 clone 还是 pull
if git_dir.is_dir():
# 已有仓库,执行 pull
cmd = ["git", "-C", str(local_path), "pull", "--ff-only"]
action = "pull"
# 检查远程地址是否变化
current_remote = subprocess.run(
["git", "-C", str(local_path), "remote", "get-url", "origin"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
current_url = current_remote.stdout.strip() if current_remote.returncode == 0 else ""
if current_url != obj.repo_url:
# 远程地址变化,删除旧目录重新 clone
logger.info("nuclei 模板仓库 %s 远程地址变化,重新 clone: %s -> %s", obj.id, current_url, obj.repo_url)
shutil.rmtree(local_path)
local_path.mkdir(parents=True, exist_ok=True)
cmd = ["git", "clone", "--depth", "1", obj.repo_url, str(local_path)]
action = "clone"
else:
# 已有仓库且地址未变,执行 pull
cmd = ["git", "-C", str(local_path), "pull", "--ff-only"]
action = "pull"
else:
# 新仓库,执行 clone
if local_path.exists() and not local_path.is_dir():

View File

@@ -8,13 +8,32 @@
2. 选择负载最低的 Worker可能是本地或远程
3. 本地 Worker直接执行 docker run
4. 远程 Worker通过 SSH 执行 docker run
5. 任务执行完自动销毁容器
5. 任务执行完自动销毁容器--rm
镜像版本管理:
- 版本锁定:使用 settings.IMAGE_TAG 确保 server 和 worker 版本一致
- 预拉取策略:安装时预拉取镜像,执行时使用 --pull=missing
- 本地开发:可通过 TASK_EXECUTOR_IMAGE 环境变量指向本地镜像
环境变量注入:
- Worker 容器不使用 env_file通过 docker run -e 动态注入
- 只注入 SERVER_URL容器启动后从配置中心获取完整配置
- 本地 WorkerSERVER_URL = http://server:{port}Docker 内部网络)
- 远程 WorkerSERVER_URL = http://{public_host}:{port}(公网地址)
任务启动流程:
1. Server 调用 execute_scan_flow() 等方法提交任务
2. select_best_worker() 从 Redis 读取心跳数据,选择负载最低的节点
3. _build_docker_command() 构建完整的 docker run 命令:
- 设置网络(本地加入 Docker 网络,远程不指定)
- 注入环境变量(-e SERVER_URL=...
- 挂载结果和日志目录(-v
- 指定执行脚本python -m apps.scan.scripts.xxx
4. _execute_docker_command() 执行命令:
- 本地subprocess.run() 直接执行
- 远程paramiko SSH 执行
5. docker run -d 立即返回容器 ID任务在后台执行
特点:
- 负载感知:任务优先分发到最空闲的机器
- 统一调度:本地和远程 Worker 使用相同的选择逻辑
@@ -134,11 +153,30 @@ class TaskDistributor:
else:
scored_workers.append((worker, score, cpu, mem))
# 降级策略:如果没有正常负载的,使用高负载中最低的
# 降级策略:如果没有正常负载的,等待后重新选择
if not scored_workers:
if high_load_workers:
logger.warning("所有 Worker 高负载,降级选择负载最低的")
scored_workers = high_load_workers
# 高负载时先等待,给系统喘息时间(默认 60 秒)
high_load_wait = getattr(settings, 'HIGH_LOAD_WAIT_SECONDS', 60)
logger.warning("所有 Worker 高负载,等待 %d 秒后重试...", high_load_wait)
time.sleep(high_load_wait)
# 重新选择(递归调用,可能负载已降下来)
# 为避免无限递归,这里直接使用高负载中最低的
high_load_workers.sort(key=lambda x: x[1])
best_worker, _, cpu, mem = high_load_workers[0]
# 发送高负载通知
from apps.common.signals import all_workers_high_load
all_workers_high_load.send(
sender=self.__class__,
worker_name=best_worker.name,
cpu=cpu,
mem=mem
)
logger.info("选择 Worker: %s (CPU: %.1f%%, MEM: %.1f%%)", best_worker.name, cpu, mem)
return best_worker
else:
logger.warning("没有可用的 Worker")
return None
@@ -194,16 +232,24 @@ class TaskDistributor:
network_arg = f"--network {settings.DOCKER_NETWORK_NAME}"
server_url = f"http://server:{settings.SERVER_PORT}"
else:
# 远程:无需指定网络,使用公网地址
# 远程:通过 Nginx 反向代理访问HTTPS不直连 8888 端口)
network_arg = ""
server_url = f"http://{settings.PUBLIC_HOST}:{settings.SERVER_PORT}"
server_url = f"https://{settings.PUBLIC_HOST}"
# 挂载路径(所有节点统一使用固定路径)
host_results_dir = settings.HOST_RESULTS_DIR # /opt/xingrin/results
host_logs_dir = settings.HOST_LOGS_DIR # /opt/xingrin/logs
# 环境变量:只需 SERVER_URL其他配置容器启动时从配置中心获取
env_vars = [f"-e SERVER_URL={shlex.quote(server_url)}"]
# 环境变量SERVER_URL + IS_LOCAL,其他配置容器启动时从配置中心获取
# IS_LOCAL 用于 Worker 向配置中心声明身份,决定返回的数据库地址
# Prefect 本地模式配置:禁用 API server 和事件系统
is_local_str = "true" if worker.is_local else "false"
env_vars = [
f"-e SERVER_URL={shlex.quote(server_url)}",
f"-e IS_LOCAL={is_local_str}",
"-e PREFECT_API_URL=", # 禁用 API server
"-e PREFECT_LOGGING_EXTRA_LOGGERS=", # 禁用 Prefect 的额外内部日志器
]
# 挂载卷
volumes = [
@@ -383,8 +429,20 @@ class TaskDistributor:
Note:
engine_config 由 Flow 内部通过 scan_id 查询数据库获取
"""
logger.info("="*60)
logger.info("execute_scan_flow 开始")
logger.info(" scan_id: %s", scan_id)
logger.info(" target_name: %s", target_name)
logger.info(" target_id: %s", target_id)
logger.info(" scan_workspace_dir: %s", scan_workspace_dir)
logger.info(" engine_name: %s", engine_name)
logger.info(" docker_image: %s", self.docker_image)
logger.info("="*60)
# 1. 等待提交间隔(后台线程执行,不阻塞 API
logger.info("等待提交间隔...")
self._wait_for_submit_interval()
logger.info("提交间隔等待完成")
# 2. 选择最佳 Worker
worker = self.select_best_worker()

View File

@@ -134,5 +134,57 @@ class WorkerService:
logger.warning(f"[卸载] Worker {worker_id} 远程卸载异常: {e}")
return False, f"远程卸载异常: {str(e)}"
def execute_remote_command(
self,
ip_address: str,
ssh_port: int,
username: str,
password: str | None,
command: str
) -> tuple[bool, str]:
"""
在远程主机上执行命令
Args:
ip_address: SSH 主机地址
ssh_port: SSH 端口
username: SSH 用户名
password: SSH 密码
command: 要执行的命令
Returns:
(success, message) 元组
"""
if not password:
return False, "未配置 SSH 密码"
try:
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(
ip_address,
port=ssh_port,
username=username,
password=password,
timeout=30
)
stdin, stdout, stderr = ssh.exec_command(command, timeout=120)
exit_status = stdout.channel.recv_exit_status()
ssh.close()
if exit_status == 0:
return True, stdout.read().decode().strip()
else:
error = stderr.read().decode().strip()
return False, error
except Exception as e:
return False, str(e)
__all__ = ["WorkerService"]

View File

@@ -116,7 +116,7 @@ class NucleiTemplateRepoViewSet(viewsets.ModelViewSet):
return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc: # noqa: BLE001
logger.error("刷新 Nuclei 模板仓库失败: %s", exc, exc_info=True)
return Response({"message": "刷新仓库失败"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response({"message": f"刷新仓库失败: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response({"message": "刷新成功", "result": result}, status=status.HTTP_200_OK)

View File

@@ -118,8 +118,36 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['post'])
def heartbeat(self, request, pk=None):
"""接收心跳上报(写 Redis首次心跳更新部署状态"""
"""
接收心跳上报(写 Redis首次心跳更新部署状态检查版本
请求体:
{
"cpu_percent": 50.0,
"memory_percent": 60.0,
"version": "v1.0.9"
}
返回:
{
"status": "ok",
"need_update": true/false,
"server_version": "v1.0.19"
}
状态流转:
┌─────────────────────────────────────────────────────────────────────┐
│ 场景 │ 状态变化 │
├─────────────────────────────┼───────────────────────────────────────┤
│ 首次心跳 │ pending/deploying → online │
│ 远程 Worker 版本不匹配 │ online → updating → (更新成功) online │
│ 远程 Worker 更新失败 │ updating → outdated │
│ 本地 Worker 版本不匹配 │ online → outdated (需手动 update.sh) │
│ 版本匹配 │ updating/outdated → online │
└─────────────────────────────┴───────────────────────────────────────┘
"""
from apps.engine.services.worker_load_service import worker_load_service
from django.conf import settings
worker = self.get_object()
info = request.data if request.data else {}
@@ -134,7 +162,122 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
worker.status = 'online'
worker.save(update_fields=['status'])
return Response({'status': 'ok'})
# 3. 版本检查:比较 agent 版本与 server 版本
agent_version = info.get('version', '')
server_version = settings.IMAGE_TAG # Server 当前版本
need_update = False
if agent_version and agent_version != 'unknown':
# 版本不匹配时通知 agent 更新
need_update = agent_version != server_version
if need_update:
logger.info(
f"Worker {worker.name} 版本不匹配: agent={agent_version}, server={server_version}"
)
# 远程 Worker服务端主动通过 SSH 触发更新
if not worker.is_local and worker.ip_address:
self._trigger_remote_agent_update(worker, server_version)
else:
# 本地 Worker 版本不匹配:标记为 outdated
# 需要用户手动执行 update.sh 更新
if worker.status != 'outdated':
worker.status = 'outdated'
worker.save(update_fields=['status'])
else:
# 版本匹配,确保状态为 online
if worker.status in ('updating', 'outdated'):
worker.status = 'online'
worker.save(update_fields=['status'])
return Response({
'status': 'ok',
'need_update': need_update,
'server_version': server_version
})
def _trigger_remote_agent_update(self, worker, target_version: str):
"""
通过 SSH 触发远程 agent 更新(后台执行,不阻塞心跳响应)
使用 Redis 锁防止重复触发(同一 worker 60秒内只触发一次
"""
import redis
from django.conf import settings as django_settings
redis_url = f"redis://{django_settings.REDIS_HOST}:{django_settings.REDIS_PORT}/{django_settings.REDIS_DB}"
redis_client = redis.from_url(redis_url)
lock_key = f"agent_update_lock:{worker.id}"
# 尝试获取锁60秒过期防止重复触发
if not redis_client.set(lock_key, "1", nx=True, ex=60):
logger.debug(f"Worker {worker.name} 更新已在进行中,跳过")
return
# 获取锁成功,设置状态为 updating
self._set_worker_status(worker.id, 'updating')
# 提取数据避免后台线程访问 ORM
worker_id = worker.id
worker_name = worker.name
ip_address = worker.ip_address
ssh_port = worker.ssh_port
username = worker.username
password = worker.password
def _async_update():
try:
logger.info(f"开始远程更新 Worker {worker_name}{target_version}")
# 构建更新命令:拉取新镜像并重启 agent
docker_user = getattr(django_settings, 'DOCKER_USER', 'yyhuni')
update_cmd = f'''
docker pull {docker_user}/xingrin-agent:{target_version} && \
docker stop xingrin-agent 2>/dev/null || true && \
docker rm xingrin-agent 2>/dev/null || true && \
docker run -d --pull=always \
--name xingrin-agent \
--restart always \
-e HEARTBEAT_API_URL="https://{django_settings.PUBLIC_HOST}" \
-e WORKER_ID="{worker_id}" \
-e IMAGE_TAG="{target_version}" \
-v /proc:/host/proc:ro \
{docker_user}/xingrin-agent:{target_version}
'''
success, message = self.worker_service.execute_remote_command(
ip_address=ip_address,
ssh_port=ssh_port,
username=username,
password=password,
command=update_cmd
)
if success:
logger.info(f"Worker {worker_name} 远程更新成功")
# 更新成功后,新 agent 心跳会自动把状态改回 online
else:
logger.warning(f"Worker {worker_name} 远程更新失败: {message}")
# 更新失败,标记为 outdated
self._set_worker_status(worker_id, 'outdated')
except Exception as e:
logger.error(f"Worker {worker_name} 远程更新异常: {e}")
self._set_worker_status(worker_id, 'outdated')
finally:
# 释放锁
redis_client.delete(lock_key)
# 后台执行,不阻塞心跳响应
threading.Thread(target=_async_update, daemon=True).start()
def _set_worker_status(self, worker_id: int, status: str):
"""更新 Worker 状态(用于后台线程)"""
try:
from apps.engine.models import WorkerNode
WorkerNode.objects.filter(id=worker_id).update(status=status)
except Exception as e:
logger.error(f"更新 Worker {worker_id} 状态失败: {e}")
@action(detail=False, methods=['post'])
def register(self, request):
@@ -177,75 +320,16 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
'created': created
})
def _get_client_ip(self, request) -> str:
"""获取客户端真实 IP"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0].strip()
return request.META.get('REMOTE_ADDR', '')
def _is_local_request(self, client_ip: str) -> bool:
"""
判断是否为本地请求Docker 网络内部)
本地请求特征:
- 来自 Docker 网络内部172.x.x.x
- 来自 localhost127.0.0.1
"""
if not client_ip:
return True # 无法获取 IP 时默认为本地
# Docker 默认网络段
if client_ip.startswith('172.') or client_ip.startswith('10.'):
return True
# localhost
if client_ip in ('127.0.0.1', '::1', 'localhost'):
return True
return False
@action(detail=False, methods=['get'])
def config(self, request):
"""
获取任务容器配置(配置中心 API
Worker 启动时调用此接口获取完整配置,实现配置中心化管理。
Worker 只需知道 SERVER_URL其他配置由此 API 动态返回
Worker 通过 IS_LOCAL 环境变量声明身份,请求时带上 ?is_local=true/false 参数
┌─────────────────────────────────────────────────────────────┐
配置分发流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Worker 启动 │
│ │ │
│ ▼ │
│ GET /api/workers/config/ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ _get_client_ip() │ ← 获取请求来源 IP │
│ │ (X-Forwarded-For │ (支持 Nginx 代理场景) │
│ │ 或 REMOTE_ADDR) │ │
│ └─────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ _is_local_request() │ ← 判断是否为 Docker 网络内部请求 │
│ │ 172.x.x.x / 10.x.x.x│ (Docker 默认网段) │
│ │ 127.0.0.1 / ::1 │ (localhost) │
│ └─────────┬───────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ 本地 Worker 远程 Worker │
│ (Docker内) (公网访问) │
│ │ │ │
│ ▼ ▼ │
│ db: postgres db: PUBLIC_HOST │
│ redis: redis redis: PUBLIC_HOST:6379 │
│ │
└─────────────────────────────────────────────────────────────┘
请求参数:
is_local: true/false - Worker 是否为本地节点Docker 网络内)
返回:
{
@@ -253,19 +337,29 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
"redisUrl": "...",
"paths": {"results": "...", "logs": "..."}
}
配置逻辑:
- 本地 Worker (is_local=true): db_host=postgres, redis=redis:6379
- 远程 Worker (is_local=false): db_host=PUBLIC_HOST, redis=PUBLIC_HOST:6379
"""
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
# 判断请求来源:本地 Worker 还是远程 Worker
# 本地 Worker 在 Docker 网络内,可以直接访问 postgres 服务
# 远程 Worker 需要通过公网 IP 访问
client_ip = self._get_client_ip(request)
is_local_worker = self._is_local_request(client_ip)
# 从请求参数获取 Worker 身份(由 Worker 自己声明)
# 不再依赖 IP 判断,避免不同网络环境下的兼容性问题
is_local_param = request.query_params.get('is_local', '').lower()
is_local_worker = is_local_param == 'true'
# 根据请求来源返回不同的数据库地址
db_host = settings.DATABASES['default']['HOST']
_is_internal_db = db_host in ('postgres', 'localhost', '127.0.0.1')
logger.info(
"Worker 配置请求 - is_local_param: %s, is_local_worker: %s, db_host: %s, is_internal_db: %s",
is_local_param, is_local_worker, db_host, _is_internal_db
)
if _is_internal_db:
# 本地数据库场景
if is_local_worker:
@@ -274,13 +368,18 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
worker_redis_url = 'redis://redis:6379/0'
else:
# 远程 Worker通过公网 IP 访问
worker_db_host = settings.PUBLIC_HOST
worker_redis_url = f'redis://{settings.PUBLIC_HOST}:6379/0'
public_host = settings.PUBLIC_HOST
if public_host in ('server', 'localhost', '127.0.0.1'):
logger.warning("远程 Worker 请求配置,但 PUBLIC_HOST=%s 不是有效的公网地址", public_host)
worker_db_host = public_host
worker_redis_url = f'redis://{public_host}:6379/0'
else:
# 远程数据库场景:所有 Worker 都用 DB_HOST
worker_db_host = db_host
worker_redis_url = getattr(settings, 'WORKER_REDIS_URL', 'redis://redis:6379/0')
logger.info("返回 Worker 配置 - db_host: %s, redis_url: %s", worker_db_host, worker_redis_url)
return Response({
'db': {
'host': worker_db_host,

View File

@@ -1,28 +1,14 @@
# 引擎配置
#
# ==================== 参数命名规范 ====================
# 所有参数统一用中划线,如 rate-limit, request-timeout, wordlist-name
# - 贴近 CLI 参数风格,用户更直观
# - 系统会自动转换为下划线供代码使用
#
# ==================== 必需参数 ====================
# - enabled: 是否启用工具true/false
# - timeout: 超时时间(秒),工具执行超过此时间会被强制终止
#
# 使用方式:
# - 在前端创建扫描引擎时,将此配置保存到数据库
# - 执行扫描时,从数据库读取配置并传递给 Flow
# - 取消注释可选参数即可启用
# 参数命名:统一用中划线(如 rate-limit系统自动转换为下划线
# 必需参数enabled是否启用、timeout超时秒数auto 表示自动计算)
# ==================== 子域名发现 ====================
#
# 流程说明:
# Stage 1: 被动收集(并行) - 必选,至少启用一个工具
# Stage 2: 字典爆破(可选) - 使用字典暴力枚举子域名
# Stage 3: 变异生成 + 验证(可选) - 基于已发现域名生成变异,流式验证存活
# Stage 4: DNS 存活验证(可选) - 验证所有候选域名是否能解析
#
# 灵活组合:可以关闭 2/3/4 中的任意阶段,最终结果会根据实际执行的阶段动态决定
# Stage 1: 被动收集(并行) - 必选,至少启用一个工具
# Stage 2: 字典爆破(可选) - 使用字典暴力枚举子域名
# Stage 3: 变异生成 + 验证(可选) - 基于已发现域名生成变异,流式验证存活
# Stage 4: DNS 存活验证(可选) - 验证所有候选域名是否能解析
#
subdomain_discovery:
# === Stage 1: 被动收集工具(并行执行)===
@@ -30,11 +16,11 @@ subdomain_discovery:
subfinder:
enabled: true
timeout: 7200 # 2小时
# threads: 10 # 可选,并发 goroutine 数
# threads: 10 # 并发 goroutine 数
amass_passive:
enabled: true
timeout: 7200 # 2小时
timeout: 7200
amass_active:
enabled: true # 主动枚举 + 爆破
@@ -43,7 +29,7 @@ subdomain_discovery:
sublist3r:
enabled: true
timeout: 7200
# threads: 50 # 可选,线程数
# threads: 50 # 线程数
assetfinder:
enabled: true
@@ -51,174 +37,123 @@ subdomain_discovery:
# === Stage 2: 主动字典爆破(可选)===
bruteforce:
enabled: false # 是否启用字典爆破
enabled: false
subdomain_bruteforce:
timeout: auto # 自动根据字典行数计算(后续代码中按行数 * 3 秒实现)
wordlist-name: subdomains-top1million-110000.txt # 字典名称,对应「字典管理」中的 Wordlist.name
timeout: auto # 自动根据字典行数计算
wordlist-name: subdomains-top1million-110000.txt # 对应「字典管理」中的 Wordlist.name
# === Stage 3: 变异生成 + 存活验证(可选,流式管道避免 OOM===
# === Stage 3: 变异生成 + 存活验证(可选)===
permutation:
enabled: true # 是否启用变异生成
enabled: true
subdomain_permutation_resolve:
timeout: 7200 # 2小时变异量大时需要更长时间
timeout: 7200
# === Stage 4: DNS 存活验证(可选)===
resolve:
enabled: true # 是否启用存活验证
enabled: true
subdomain_resolve:
timeout: auto # 自动根据候选子域数量计算(在 Flow 中按行数 * 3 秒实现)
timeout: auto # 自动根据候选子域数量计算
# ==================== 端口扫描 ====================
port_scan:
tools:
naabu_active:
enabled: true
timeout: auto # 自动计算(根据:目标数 × 端口数 × 0.5秒)
# 例如100个域名 × 100个端口 × 0.5 = 5000秒
# 10个域名 × 1000个端口 × 0.5 = 5000秒
# 超时范围60秒 ~ 2天172800秒
# 或者手动指定timeout: 3600
threads: 200 # 可选,并发连接数(默认 5
# ports: 1-65535 # 可选,扫描端口范围(默认 1-65535
top-ports: 100 # 可选Scan for nmap top 100 ports影响 timeout 计算)
rate: 10 # 可选,扫描速率(默认 10
timeout: auto # 自动计算(目标数 × 端口数 × 0.5秒),范围 60秒 ~ 2天
threads: 200 # 并发连接数(默认 5
# ports: 1-65535 # 扫描端口范围(默认 1-65535
top-ports: 100 # 扫描 nmap top 100 端口
rate: 10 # 扫描速率(默认 10
naabu_passive:
enabled: true
timeout: auto # 自动计算(被动扫描通常较快,端口数默认为 100
# 被动扫描,使用被动数据源,无需额外配置
timeout: auto # 被动扫描通常较快
# ==================== 站点扫描 ====================
site_scan:
tools:
httpx:
enabled: true
timeout: auto # 自动计算(根据URL数量每个URL 1秒)
# 或者手动指定timeout: 3600
# threads: 50 # 可选并发线程数httpx 默认 50
# rate-limit: 150 # 可选每秒发送的请求数量httpx 默认 150
# request-timeout: 10 # 可选单个请求的超时时间httpx 默认 10
# retries: 2 # 可选,请求失败重试次数
timeout: auto # 自动计算(每个 URL 约 1 秒)
# threads: 50 # 并发线程数(默认 50
# rate-limit: 150 # 每秒请求数(默认 150
# request-timeout: 10 # 单个请求超时秒数(默认 10
# retries: 2 # 请求失败重试次数
# ==================== 目录扫描 ====================
directory_scan:
tools:
ffuf:
enabled: true
timeout: auto # 自动计算超时时间(根据字典行数)
# 计算公式:字典行数 × 0.02秒/词
# 超时范围60秒 ~ 7200秒2小时
# 也可以手动指定固定超时(如 300
wordlist-name: dir_default.txt # 字典名称(必需),对应「字典管理」中唯一的 Wordlist.name
# 安装时会自动初始化名为 dir_default.txt 的默认目录字典
# ffuf 会逐行读取字典文件,将每行作为 FUZZ 关键字的替换值
delay: 0.1-2.0 # Seconds of delay between requests, or a range of random delay
# For example "0.1" or "0.1-2.0"
threads: 10 # Number of concurrent threads (default: 40)
request-timeout: 10 # HTTP request timeout in seconds (default: 10)
match-codes: 200,201,301,302,401,403 # Match HTTP status codes, comma separated
# rate: 0 # Rate of requests per second (default: 0)
timeout: auto # 自动计算(字典行数 × 0.02秒),范围 60秒 ~ 2小时
wordlist-name: dir_default.txt # 对应「字典管理」中的 Wordlist.name
delay: 0.1-2.0 # 请求间隔,支持范围随机(如 "0.1-2.0"
threads: 10 # 并发线程数(默认 40
request-timeout: 10 # HTTP 请求超时秒数(默认 10
match-codes: 200,201,301,302,401,403 # 匹配的 HTTP 状态码
# rate: 0 # 每秒请求数(默认 0 不限制)
# ==================== URL 获取 ====================
url_fetch:
tools:
waymore:
enabled: true
timeout: 3600 # 工具级别总超时:固定 3600 秒(按域名 target_name 输入)
# 如果目标较大或希望更快/更慢,可根据需要手动调整秒数
# 输入类型domain_name域名级别自动去重同域名站点
timeout: 3600 # 固定 1 小时(按域名输入)
katana:
enabled: true
timeout: auto # 工具级别总超时:自动计算(根据站点数量)
# 或手动指定timeout: 300
# ========== 核心功能参数(已在命令中固定开启) ==========
# -jc: JavaScript 爬取 + 自动解析 .js 文件里的所有端点(最重要)
# -xhr: 从 JS 中提取 XHR/Fetch 请求的 API 路径(再多挖 10-20% 隐藏接口)
# -kf all: 自动 fuzz 所有已知敏感文件(.env、.git、backup、config 等 5000+ 条)
# -fs rdn: 智能过滤重复+噪声路径(分页、?id=1/2/3 全干掉,输出极干净)
# ========== 可选参数(推荐配置) ==========
depth: 5 # 爬取最大深度(平衡深度与时间,默认 3推荐 5
threads: 10 # 全局并发数(极低并发最像真人,推荐 10
rate-limit: 30 # 全局硬限速:每秒最多 30 个请求WAF 几乎不报警)
random-delay: 1 # 每次请求之间随机延迟 0.5~1.5 秒(再加一层人性化)
retry: 2 # 失败请求自动重试 2 次(网络抖动不丢包)
request-timeout: 12 # 单请求超时 12 秒防卡死katana 参数名是 -timeout
# 输入类型url站点级别每个站点单独爬取
timeout: auto # 自动计算(根据站点数量)
depth: 5 # 爬取最大深度(默认 3
threads: 10 # 全局并发数
rate-limit: 30 # 每秒最多请求数
random-delay: 1 # 请求间随机延迟秒数
retry: 2 # 失败重试次数
request-timeout: 12 # 单请求超时秒数
uro:
enabled: true
timeout: auto # 自动计算(根据 URL 数量,每 100 个约 1 秒)
# 范围30 秒 ~ 300 秒
# 或手动指定timeout: 60
# ========== 可选参数 ==========
# whitelist: # 只保留指定扩展名的 URLphp,asp,jsp
timeout: auto # 自动计算(每 100 个 URL 约 1 秒),范围 30 ~ 300 秒
# whitelist: # 只保留指定扩展名
# - php
# - asp
# blacklist: # 排除指定扩展名的 URL(静态资源)
# blacklist: # 排除指定扩展名(静态资源)
# - jpg
# - jpeg
# - png
# - gif
# - svg
# - ico
# - css
# - woff
# - woff2
# - ttf
# - eot
# - mp4
# - mp3
# - pdf
# filters: # 额外的过滤规则,参考 uro 文档
# - hasparams # 只保留有参数的 URL
# - hasext # 只保留有扩展名的 URL
# - vuln # 只保留可能有漏洞的 URL
# 用途:清理合并后的 URL 列表,去除冗余和无效 URL
# 输入类型merged_file合并后的 URL 文件)
# 输出:清理后的 URL 列表
# filters: # 额外过滤规则
# - hasparams # 只保留有参数的 URL
# - vuln # 只保留可能有漏洞的 URL
httpx:
enabled: true
timeout: auto # 自动计算(根据 URL 数量,每个 URL 1 秒)
# 或手动指定timeout: 600
# threads: 50 # 可选并发线程数httpx 默认 50
# rate-limit: 150 # 可选每秒发送的请求数量httpx 默认 150
# request-timeout: 10 # 可选单个请求的超时时间httpx 默认 10
# retries: 2 # 可选,请求失败重试次数
# 用途:判断 URL 存活,过滤无效 URL
# 输入类型url_fileURL 列表文件)
# 输出:存活的 URL 及其响应信息status, title, server, tech 等)
timeout: auto # 自动计算(每个 URL 1 秒)
# threads: 50 # 并发线程数(默认 50
# rate-limit: 150 # 每秒请求数(默认 150
# request-timeout: 10 # 单个请求超时秒数(默认 10
# retries: 2 # 请求失败重试次数
# ==================== 漏洞扫描 ====================
vuln_scan:
tools:
dalfox_xss:
enabled: true
timeout: auto # 自动计算(根据 endpoints 行数 × 100 秒),或手动指定秒数如 timeout: 600
request-timeout: 10 # Dalfox 单个请求超时时间,对应命令行 --timeout
timeout: auto # 自动计算endpoints 行数 × 100 秒)
request-timeout: 10 # 单个请求超时秒数
only-poc: r # 只输出 POC 结果r: 反射型)
ignore-return: "302,404,403" # 忽略这些返回码
# blind-xss-server: xxx # 可选:盲打 XSS 回连服务地址,需要时再开启
delay: 100 # Dalfox 扫描内部延迟参数
worker: 10 # Dalfox worker 数量
user-agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" # 默认 UA可根据需要修改
ignore-return: "302,404,403" # 忽略返回码
delay: 100 # 扫描内部延迟
worker: 10 # worker 数量
user-agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
# blind-xss-server: xxx # 盲打 XSS 回连服务地址
nuclei:
enabled: true
timeout: auto # 自动计算(根据 endpoints 行数),或手动指定秒数
template-repo-names: # 模板仓库列表(必填,数组写法)对应「Nuclei 模板」中的仓库名
- nuclei-templates # Worker 会自动同步到与 Server 一致的 commit 版本
# - nuclei-custom # 可追加自定义仓库,按顺序依次 -t 传入
concurrency: 25 # 并发数(默认 25
rate-limit: 150 # 每秒请求数限制(默认 150
request-timeout: 5 # 单个请求超时秒数(默认 5
severity: medium,high,critical # 只扫描中高危,降低噪音(逗号分隔)
# tags: cve,rce # 可选:只使用指定标签的模板
timeout: auto # 自动计算(根据 endpoints 行数)
template-repo-names: # 模板仓库列表对应「Nuclei 模板」中的仓库名
- nuclei-templates
# - nuclei-custom # 可追加自定义仓库
concurrency: 25 # 并发数(默认 25
rate-limit: 150 # 每秒请求数限制(默认 150
request-timeout: 5 # 单个请求超时秒数(默认 5
severity: medium,high,critical # 只扫描中高危
# tags: cve,rce # 只使用指定标签的模板

View File

@@ -372,19 +372,17 @@ def port_scan_flow(
端口扫描 Flow
主要功能:
1. 扫描目标域名的开放端口(核心目标)
2. 发现域名对应的 IP 地址(附带产物)
3. 保存 IP 和端口的关联关系
1. 扫描目标域名/IP 的开放端口
2. 保存 host + ip + port 三元映射到 HostPortMapping 表
输出资产:
- Port开放的端口列表主要资产
- IPAddress域名对应的 IP 地址(附带资产)
- HostPortMapping主机端口映射host + ip + port 三元组
工作流程:
Step 0: 创建工作目录
Step 1: 导出域名列表到文件(供扫描工具使用)
Step 2: 解析配置,获取启用的工具
Step 3: 串行执行扫描工具,运行端口扫描工具并实时解析输出到数据库(Subdomain → IPAddress → Port
Step 3: 串行执行扫描工具,运行端口扫描工具并实时解析输出到数据库(→ HostPortMapping
Args:
scan_id: 扫描任务 ID
@@ -418,10 +416,8 @@ def port_scan_flow(
RuntimeError: 执行失败
Note:
端口扫描的输出必然包含 IP 信息,因为:
- 扫描工具需要解析域名 → IP
- 端口属于 IP而不是直接属于域名
- 同一域名可能对应多个 IPCDN、负载均衡
端口扫描工具(如 naabu会解析域名获取 IP输出 host + ip + port 三元组。
同一 host 可能对应多个 IPCDN、负载均衡因此使用三元映射表存储。
"""
try:
# 参数验证

View File

@@ -1,5 +1,5 @@
"""
子域名发现扫描 Flow(增强版)
子域名发现扫描 Flow
负责编排子域名发现扫描的完整流程
@@ -343,7 +343,7 @@ def subdomain_discovery_flow(
scan_workspace_dir: str,
enabled_tools: dict
) -> dict:
"""子域名发现扫描流程(增强版)
"""子域名发现扫描流程
工作流程4 阶段):
Stage 1: 被动收集(并行) - 必选
@@ -410,7 +410,7 @@ def subdomain_discovery_flow(
# 验证成功后打印日志
logger.info(
"="*60 + "\n" +
"开始子域名发现扫描(增强版)\n" +
"开始子域名发现扫描\n" +
f" Scan ID: {scan_id}\n" +
f" Domain: {domain_name}\n" +
f" Workspace: {scan_workspace_dir}\n" +

View File

@@ -6,7 +6,7 @@
import logging
from django.dispatch import receiver
from apps.common.signals import vulnerabilities_saved, worker_delete_failed
from apps.common.signals import vulnerabilities_saved, worker_delete_failed, all_workers_high_load
from apps.scan.notifications import create_notification, NotificationLevel, NotificationCategory
logger = logging.getLogger(__name__)
@@ -80,3 +80,15 @@ def on_worker_delete_failed(sender, worker_name, message, **kwargs):
category=NotificationCategory.SYSTEM
)
logger.warning("Worker 删除失败通知已发送 - worker=%s, message=%s", worker_name, message)
@receiver(all_workers_high_load)
def on_all_workers_high_load(sender, worker_name, cpu, mem, **kwargs):
"""所有 Worker 高负载时的通知处理"""
create_notification(
title="系统负载较高",
message=f"所有节点负载较高,已选择负载最低的节点 {worker_name}CPU: {cpu:.1f}%, 内存: {mem:.1f}%)执行任务,扫描速度可能受影响",
level=NotificationLevel.MEDIUM,
category=NotificationCategory.SYSTEM
)
logger.warning("高负载通知已发送 - worker=%s, cpu=%.1f%%, mem=%.1f%%", worker_name, cpu, mem)

View File

@@ -3,10 +3,14 @@
import logging
import time
import requests
import urllib3
from .models import Notification, NotificationSettings
from .types import NotificationLevel, NotificationCategory
from .repositories import DjangoNotificationRepository, NotificationSettingsRepository
# 禁用自签名证书的 SSL 警告(远程 Worker 回调场景)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = logging.getLogger(__name__)
@@ -314,7 +318,8 @@ def _push_via_api_callback(notification: Notification, server_url: str) -> None:
'created_at': notification.created_at.isoformat()
}
resp = requests.post(callback_url, json=data, timeout=5)
# verify=False: 远程 Worker 回调 Server 时可能使用自签名证书
resp = requests.post(callback_url, json=data, timeout=5, verify=False)
resp.raise_for_status()
logger.debug(f"通知回调推送成功 - ID: {notification.id}")

View File

@@ -6,14 +6,32 @@
必须在 Django 导入之前获取配置并设置环境变量。
"""
import argparse
from apps.common.container_bootstrap import fetch_config_and_setup_django
import sys
import os
import traceback
def main():
print("="*60)
print("run_initiate_scan.py 启动")
print(f" Python: {sys.version}")
print(f" CWD: {os.getcwd()}")
print(f" SERVER_URL: {os.environ.get('SERVER_URL', 'NOT SET')}")
print("="*60)
# 1. 从配置中心获取配置并初始化 Django必须在 Django 导入之前)
fetch_config_and_setup_django()
print("[1/4] 从配置中心获取配置...")
try:
from apps.common.container_bootstrap import fetch_config_and_setup_django
fetch_config_and_setup_django()
print("[1/4] ✓ 配置获取成功")
except Exception as e:
print(f"[1/4] ✗ 配置获取失败: {e}")
traceback.print_exc()
sys.exit(1)
# 2. 解析命令行参数
print("[2/4] 解析命令行参数...")
parser = argparse.ArgumentParser(description="执行扫描初始化 Flow")
parser.add_argument("--scan_id", type=int, required=True, help="扫描任务 ID")
parser.add_argument("--target_name", type=str, required=True, help="目标名称")
@@ -23,21 +41,41 @@ def main():
parser.add_argument("--scheduled_scan_name", type=str, default=None, help="定时扫描任务名称(可选)")
args = parser.parse_args()
print(f"[2/4] ✓ 参数解析成功:")
print(f" scan_id: {args.scan_id}")
print(f" target_name: {args.target_name}")
print(f" target_id: {args.target_id}")
print(f" scan_workspace_dir: {args.scan_workspace_dir}")
print(f" engine_name: {args.engine_name}")
print(f" scheduled_scan_name: {args.scheduled_scan_name}")
# 3. 现在可以安全导入 Django 相关模块
from apps.scan.flows.initiate_scan_flow import initiate_scan_flow
print("[3/4] 导入 initiate_scan_flow...")
try:
from apps.scan.flows.initiate_scan_flow import initiate_scan_flow
print("[3/4] ✓ 导入成功")
except Exception as e:
print(f"[3/4] ✗ 导入失败: {e}")
traceback.print_exc()
sys.exit(1)
# 4. 执行 Flow
result = initiate_scan_flow(
scan_id=args.scan_id,
target_name=args.target_name,
target_id=args.target_id,
scan_workspace_dir=args.scan_workspace_dir,
engine_name=args.engine_name,
scheduled_scan_name=args.scheduled_scan_name,
)
print(f"Flow 执行完成: {result}")
print("[4/4] 执行 initiate_scan_flow...")
try:
result = initiate_scan_flow(
scan_id=args.scan_id,
target_name=args.target_name,
target_id=args.target_id,
scan_workspace_dir=args.scan_workspace_dir,
engine_name=args.engine_name,
scheduled_scan_name=args.scheduled_scan_name,
)
print("[4/4] ✓ Flow 执行完成")
print(f"结果: {result}")
except Exception as e:
print(f"[4/4] ✗ Flow 执行失败: {e}")
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":

View File

@@ -266,15 +266,26 @@ class ScanCreationService:
Args:
scan_data: 扫描任务数据列表
"""
logger.info("="*60)
logger.info("开始分发扫描任务到 Workers - 数量: %d", len(scan_data))
logger.info("="*60)
# 后台线程需要新的数据库连接
connection.close()
logger.info("已关闭旧数据库连接,准备获取新连接")
distributor = get_task_distributor()
logger.info("TaskDistributor 初始化完成")
scan_repo = DjangoScanRepository()
logger.info("ScanRepository 初始化完成")
for data in scan_data:
scan_id = data['scan_id']
logger.info("-"*40)
logger.info("准备分发扫描任务 - Scan ID: %s, Target: %s", scan_id, data['target_name'])
try:
logger.info("调用 distributor.execute_scan_flow...")
success, message, container_id, worker_id = distributor.execute_scan_flow(
scan_id=scan_id,
target_name=data['target_name'],
@@ -284,20 +295,29 @@ class ScanCreationService:
scheduled_scan_name=data.get('scheduled_scan_name'),
)
logger.info(
"execute_scan_flow 返回 - success: %s, message: %s, container_id: %s, worker_id: %s",
success, message, container_id, worker_id
)
if success:
if container_id:
scan_repo.append_container_id(scan_id, container_id)
logger.info("已记录 container_id: %s", container_id)
if worker_id:
scan_repo.update_worker(scan_id, worker_id)
logger.info("已记录 worker_id: %s", worker_id)
logger.info(
"✓ 扫描任务已提交 - Scan ID: %s, Worker: %s",
scan_id, worker_id
)
else:
logger.error("execute_scan_flow 返回失败 - message: %s", message)
raise Exception(message)
except Exception as e:
logger.error("提交扫描任务失败 - Scan ID: %s, 错误: %s", scan_id, e)
logger.exception("详细堆栈:")
try:
scan_repo.update_status(
scan_id,

View File

@@ -157,6 +157,51 @@ class ScanService:
"""取消所有正在运行的阶段(委托给 ScanStateService"""
return self.state_service.cancel_running_stages(scan_id, final_status)
# TODO待接入
def add_command_to_scan(self, scan_id: int, stage_name: str, tool_name: str, command: str) -> bool:
"""
增量添加命令到指定扫描阶段
Args:
scan_id: 扫描任务ID
stage_name: 阶段名称(如 'subdomain_discovery', 'port_scan'
tool_name: 工具名称
command: 执行命令
Returns:
bool: 是否成功添加
"""
try:
scan = self.get_scan(scan_id, prefetch_relations=False)
if not scan:
logger.error(f"扫描任务不存在: {scan_id}")
return False
stage_progress = scan.stage_progress or {}
# 确保指定阶段存在
if stage_name not in stage_progress:
stage_progress[stage_name] = {'status': 'running', 'commands': []}
# 确保 commands 列表存在
if 'commands' not in stage_progress[stage_name]:
stage_progress[stage_name]['commands'] = []
# 增量添加命令
command_entry = f"{tool_name}: {command}"
stage_progress[stage_name]['commands'].append(command_entry)
scan.stage_progress = stage_progress
scan.save(update_fields=['stage_progress'])
command_count = len(stage_progress[stage_name]['commands'])
logger.info(f"✓ 记录命令: {stage_name}.{tool_name} (总计: {command_count})")
return True
except Exception as e:
logger.error(f"记录命令失败: {e}")
return False
# ==================== 删除和控制方法(委托给 ScanControlService ====================
def delete_scans_two_phase(self, scan_ids: List[int]) -> dict:

View File

@@ -225,6 +225,13 @@ def _parse_and_validate_line(line: str) -> Optional[PortScanRecord]:
ip = line_data.get('ip', '').strip()
port = line_data.get('port')
logger.debug("解析到的主机名: %s, IP: %s, 端口: %s", host, ip, port)
if not host and ip:
host = ip
logger.debug("主机名为空,使用 IP 作为 host")
# 步骤 4: 验证字段不为空
if not host or not ip or port is None:
logger.warning(

View File

@@ -51,6 +51,18 @@ class ServiceSet:
)
def _sanitize_string(value: str) -> str:
"""
清理字符串中的 NUL 字符和其他不可打印字符
PostgreSQL 不允许字符串字段包含 NUL (0x00) 字符
"""
if not value:
return value
# 移除 NUL 字符
return value.replace('\x00', '')
def _parse_and_validate_line(line: str) -> Optional[dict]:
"""
解析并验证单行 httpx JSON 输出
@@ -64,6 +76,9 @@ def _parse_and_validate_line(line: str) -> Optional[dict]:
只返回存活的 URL2xx/3xx 状态码)
"""
try:
# 清理 NUL 字符后再解析 JSON
line = _sanitize_string(line)
# 解析 JSON
try:
line_data = json.loads(line)
@@ -87,16 +102,16 @@ def _parse_and_validate_line(line: str) -> Optional[dict]:
# 只保存存活的 URL2xx 或 3xx
if status_code and (200 <= status_code < 400):
return {
'url': url,
'host': line_data.get('host', ''), # 从 httpx 输出中提取 host
'url': _sanitize_string(url),
'host': _sanitize_string(line_data.get('host', '')),
'status_code': status_code,
'title': line_data.get('title', ''),
'title': _sanitize_string(line_data.get('title', '')),
'content_length': line_data.get('content_length', 0),
'content_type': line_data.get('content_type', ''),
'webserver': line_data.get('webserver', ''),
'location': line_data.get('location', ''),
'content_type': _sanitize_string(line_data.get('content_type', '')),
'webserver': _sanitize_string(line_data.get('webserver', '')),
'location': _sanitize_string(line_data.get('location', '')),
'tech': line_data.get('tech', []),
'body_preview': line_data.get('body_preview', ''),
'body_preview': _sanitize_string(line_data.get('body_preview', '')),
'vhost': line_data.get('vhost', False),
}
else:
@@ -104,7 +119,7 @@ def _parse_and_validate_line(line: str) -> Optional[dict]:
return None
except Exception as e:
logger.error("解析行数据异常: %s - 数据: %s", e, line[:100])
logger.error("解析行数据异常: %s - 数据: %s", e, line[:100] if line else 'empty')
return None

View File

@@ -7,6 +7,7 @@
import logging
import os
import ssl
from pathlib import Path
from urllib import request as urllib_request
from urllib import parse as urllib_parse
@@ -81,15 +82,20 @@ def ensure_wordlist_local(wordlist_name: str) -> str:
raise RuntimeError(
"无法确定 Django API 地址:请配置 SERVER_URL 或 PUBLIC_HOST 环境变量"
)
server_port = getattr(settings, 'SERVER_PORT', '8888')
api_base = f"http://{public_host}:{server_port}/api"
# 远程 Worker 通过 nginx HTTPS 访问,不再直连 8888
api_base = f"https://{public_host}/api"
query = urllib_parse.urlencode({'wordlist': wordlist_name})
download_url = f"{api_base.rstrip('/')}/wordlists/download/?{query}"
logger.info("从后端下载字典: %s -> %s", download_url, local_path)
try:
with urllib_request.urlopen(download_url) as resp:
# 创建不验证 SSL 的上下文(远程 Worker 可能使用自签名证书)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
with urllib_request.urlopen(download_url, context=ssl_context) as resp:
if resp.status != 200:
raise RuntimeError(f"下载字典失败HTTP {resp.status}")
data = resp.read()

View File

@@ -1,7 +1,7 @@
#!/bin/bash
# ============================================
# XingRin Agent
# 用途:心跳上报 + 负载监控
# 用途:心跳上报 + 负载监控 + 版本检查
# 适用:远程 VPS 或 Docker 容器内
# ============================================
@@ -17,6 +17,9 @@ SRC_DIR="${MARKER_DIR}/src"
ENV_FILE="${SRC_DIR}/backend/.env"
INTERVAL=${AGENT_INTERVAL:-3}
# Agent 版本(从环境变量获取,由 Docker 镜像构建时注入)
AGENT_VERSION="${IMAGE_TAG:-unknown}"
# 颜色定义
GREEN='\033[0;32m'
RED='\033[0;31m'
@@ -52,7 +55,7 @@ if [ "$RUN_MODE" = "remote" ] && [ -f "$ENV_FILE" ]; then
fi
# 获取配置
# SERVER_URL: 后端 API 地址(容器内用 http://server:8888远程用公网地址
# SERVER_URL: 后端 API 地址(容器内用 http://server:8888远程用 https://{PUBLIC_HOST}
API_URL="${HEARTBEAT_API_URL:-${SERVER_URL:-}}"
WORKER_NAME="${WORKER_NAME:-}"
IS_LOCAL="${IS_LOCAL:-false}"
@@ -90,7 +93,7 @@ register_worker() {
EOF
)
RESPONSE=$(curl -s -X POST \
RESPONSE=$(curl -k -s -X POST \
-H "Content-Type: application/json" \
-d "$REGISTER_DATA" \
"${API_URL}/api/workers/register/" 2>/dev/null)
@@ -113,7 +116,7 @@ if [ -z "$WORKER_ID" ]; then
# 等待 Server 就绪
log "等待 Server 就绪..."
for i in $(seq 1 30); do
if curl -s "${API_URL}/api/" > /dev/null 2>&1; then
if curl -k -s "${API_URL}/api/" > /dev/null 2>&1; then
log "${GREEN}Server 已就绪${NC}"
break
fi
@@ -172,22 +175,72 @@ while true; do
fi
# 构建 JSON 数据(使用数值而非字符串,便于比较和排序)
# 包含版本号,供 Server 端检查版本一致性
JSON_DATA=$(cat <<EOF
{
"cpu_percent": $CPU_PERCENT,
"memory_percent": $MEM_PERCENT
"memory_percent": $MEM_PERCENT,
"version": "$AGENT_VERSION"
}
EOF
)
# 发送心跳
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
# 发送心跳,获取响应内容
RESPONSE_FILE=$(mktemp)
HTTP_CODE=$(curl -k -s -o "$RESPONSE_FILE" -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$JSON_DATA" \
"${API_URL}/api/workers/${WORKER_ID}/heartbeat/" 2>/dev/null || echo "000")
RESPONSE_BODY=$(cat "$RESPONSE_FILE" 2>/dev/null)
rm -f "$RESPONSE_FILE"
if [ "$RESPONSE" != "200" ] && [ "$RESPONSE" != "201" ]; then
log "${YELLOW}心跳发送失败 (HTTP $RESPONSE)${NC}"
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
log "${YELLOW}心跳发送失败 (HTTP $HTTP_CODE)${NC}"
else
# 检查是否需要更新
NEED_UPDATE=$(echo "$RESPONSE_BODY" | grep -oE '"need_update":\s*(true|false)' | grep -oE '(true|false)')
if [ "$NEED_UPDATE" = "true" ]; then
SERVER_VERSION=$(echo "$RESPONSE_BODY" | grep -oE '"server_version":\s*"[^"]+"' | sed 's/.*"\([^"]*\)"$/\1/')
log "${YELLOW}检测到版本不匹配: Agent=$AGENT_VERSION, Server=$SERVER_VERSION${NC}"
log "${GREEN}正在自动更新...${NC}"
# 执行自动更新
if [ "$RUN_MODE" = "container" ]; then
# 容器模式:通知外部重启(退出后由 docker-compose restart policy 重启)
log "容器模式:退出以触发重启更新"
exit 0
else
# 远程模式:拉取新镜像并重启 agent 容器
log "远程模式:更新 agent 镜像..."
DOCKER_USER="${DOCKER_USER:-yyhuni}"
NEW_IMAGE="${DOCKER_USER}/xingrin-agent:${SERVER_VERSION}"
# 拉取新镜像
if $DOCKER_CMD pull "$NEW_IMAGE" 2>/dev/null; then
log "${GREEN}镜像拉取成功: $NEW_IMAGE${NC}"
# 停止当前容器并用新镜像重启
CONTAINER_NAME="xingrin-agent"
$DOCKER_CMD stop "$CONTAINER_NAME" 2>/dev/null || true
$DOCKER_CMD rm "$CONTAINER_NAME" 2>/dev/null || true
# 重新启动(使用相同的环境变量)
$DOCKER_CMD run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-e HEARTBEAT_API_URL="$API_URL" \
-e WORKER_ID="$WORKER_ID" \
-e IMAGE_TAG="$SERVER_VERSION" \
-v /proc:/host/proc:ro \
"$NEW_IMAGE"
log "${GREEN}Agent 已更新到 $SERVER_VERSION${NC}"
exit 0
else
log "${RED}镜像拉取失败: $NEW_IMAGE${NC}"
fi
fi
fi
fi
# 休眠

View File

@@ -60,12 +60,14 @@ start_agent() {
log_info "=========================================="
log_info "启动 agent 容器..."
# --pull=missing 只在本地没有镜像时才拉取,避免意外更新
# --pull=missing: 本地没有镜像时才拉取
# 版本更新由服务端通过 SSH 显式 docker pull 触发
docker run -d --pull=missing \
--name ${CONTAINER_NAME} \
--restart always \
-e SERVER_URL="${PRESET_SERVER_URL}" \
-e WORKER_ID="${PRESET_WORKER_ID}" \
-e IMAGE_TAG="${IMAGE_TAG}" \
-v /proc:/host/proc:ro \
${IMAGE}

View File

@@ -1,4 +1,8 @@
#!/bin/bash
# 目前采用github action自动版本构建
# git tag v1.0.9
# git push origin v1.0.9
# ============================================
# Docker Hub 镜像推送脚本
# 用途:构建并推送所有服务镜像到 Docker Hub

View File

@@ -15,14 +15,14 @@ REDIS_PORT=6379
REDIS_DB=0
# ==================== 服务端口配置 ====================
# SERVER_PORT 为 Django / uvicorn 对外端口
# SERVER_PORT 为 Django / uvicorn 容器内部端口(由 nginx 反代,对公网不直接暴露)
SERVER_PORT=8888
# ==================== 远程 Worker 配置 ====================
# 供远程 Worker 访问主服务器的地址:
# - 仅本地部署serverDocker 内部服务名)
# - 有远程 Worker改为主服务器外网 IP如 192.168.1.100
# 注意:远程 Worker 访问数据库/Redis 也会使用此地址(除非配置了远程 PostgreSQL
# - 有远程 Worker改为主服务器外网 IP 或域名(如 192.168.1.100 或 xingrin.example.com
# 注意:远程 Worker 会通过 https://{PUBLIC_HOST} 访问nginx 反代到后端 8888
PUBLIC_HOST=server
# ==================== Django 核心配置 ====================

View File

@@ -1,12 +1,15 @@
# ============================================
# XingRin Agent - 轻量心跳上报镜像
# 用途:心跳上报 + 负载监控
# 用途:心跳上报 + 负载监控 + 版本检查
# 基础镜像Alpine Linux (~5MB)
# 最终大小:~10MB
# ============================================
FROM alpine:3.19
# 构建参数:版本号
ARG IMAGE_TAG=unknown
# 安装必要工具
RUN apk add --no-cache \
bash \
@@ -17,6 +20,9 @@ RUN apk add --no-cache \
COPY backend/scripts/worker-deploy/agent.sh /app/agent.sh
RUN chmod +x /app/agent.sh
# 将版本号写入环境变量(运行时可用)
ENV IMAGE_TAG=${IMAGE_TAG}
# 工作目录
WORKDIR /app

View File

@@ -37,8 +37,6 @@ services:
context: ..
dockerfile: docker/server/Dockerfile
restart: always
ports:
- "${SERVER_PORT}:8888"
env_file:
- .env
depends_on:
@@ -56,19 +54,19 @@ services:
retries: 3
start_period: 60s
# Agent心跳上报 + 负载监控
# Agent心跳上报 + 负载监控 + 版本检查
agent:
build:
context: ..
dockerfile: docker/worker/Dockerfile
dockerfile: docker/agent/Dockerfile
args:
IMAGE_TAG: ${IMAGE_TAG:-dev}
restart: always
env_file:
- .env
environment:
- SERVER_URL=http://server:8888
- WORKER_NAME=本地节点
- IS_LOCAL=true
command: bash /app/backend/scripts/worker-deploy/agent.sh
- IMAGE_TAG=${IMAGE_TAG:-dev}
depends_on:
server:
condition: service_healthy
@@ -101,6 +99,18 @@ services:
# SSL 证书挂载(方便更新)
- ./nginx/ssl:/etc/nginx/ssl:ro
# Worker扫描任务执行容器开发模式下构建
worker:
build:
context: ..
dockerfile: docker/worker/Dockerfile
image: docker-worker:${IMAGE_TAG:-latest}-dev
restart: "no"
volumes:
- /opt/xingrin/results:/app/backend/results
- /opt/xingrin/logs:/app/backend/logs
command: echo "Worker image built for development"
volumes:
postgres_data:

View File

@@ -41,8 +41,6 @@ services:
server:
image: ${DOCKER_USER:-yyhuni}/xingrin-server:${IMAGE_TAG:?IMAGE_TAG is required}
restart: always
ports:
- "${SERVER_PORT}:8888"
env_file:
- .env
depends_on:
@@ -74,6 +72,7 @@ services:
- SERVER_URL=http://server:8888
- WORKER_NAME=本地节点
- IS_LOCAL=true
- IMAGE_TAG=${IMAGE_TAG}
depends_on:
server:
condition: service_healthy

View File

@@ -27,7 +27,9 @@ COPY frontend/ ./
# 设置环境变量(构建时使用)
ARG NEXT_PUBLIC_API_URL
ARG IMAGE_TAG=unknown
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_PUBLIC_VERSION=${IMAGE_TAG}
# Docker 内部网络使用服务名 server 作为后端地址
ENV API_HOST=server

View File

@@ -36,6 +36,9 @@ http {
client_max_body_size 50m;
# 指纹特征 - 用于 FOFA/Shodan 等搜索引擎识别
add_header X-Powered-By "Xingrin ASM" always;
location /api/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -27,10 +27,10 @@ check_docker() {
# ==================== Docker Compose 命令检测 ====================
detect_compose_cmd() {
if command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
elif docker compose version >/dev/null 2>&1; then
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
log_error "未检测到 docker-compose 或 docker compose。"
exit 1

97
docker/scripts/setup-swap.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/bin/bash
#
# Ubuntu/Debian 一键开启交换分区脚本
# 用法: sudo ./setup-swap.sh [大小GB]
# 示例: sudo ./setup-swap.sh 4 # 创建 4GB 交换分区
# sudo ./setup-swap.sh # 默认创建与内存相同大小的交换分区
#
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# 检查 root 权限
if [ "$EUID" -ne 0 ]; then
log_error "请使用 sudo 运行此脚本"
exit 1
fi
# 检查是否已有交换分区
CURRENT_SWAP_KB=$(grep SwapTotal /proc/meminfo | awk '{print $2}')
CURRENT_SWAP_GB=$(awk "BEGIN {printf \"%.0f\", $CURRENT_SWAP_KB / 1024 / 1024}")
if [ "$CURRENT_SWAP_GB" -gt 0 ]; then
log_warn "系统已有 ${CURRENT_SWAP_GB}GB 交换分区"
swapon --show
read -p "是否继续添加新的交换分区?(y/N) " -r
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "已取消"
exit 0
fi
fi
# 获取系统内存大小GB四舍五入
TOTAL_MEM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}')
TOTAL_MEM_GB=$(awk "BEGIN {printf \"%.0f\", $TOTAL_MEM_KB / 1024 / 1024}")
# 确定交换分区大小
if [ -n "$1" ]; then
SWAP_SIZE_GB=$1
else
# 默认与内存相同,最小 1GB最大 8GB
SWAP_SIZE_GB=$TOTAL_MEM_GB
[ "$SWAP_SIZE_GB" -lt 1 ] && SWAP_SIZE_GB=1
[ "$SWAP_SIZE_GB" -gt 8 ] && SWAP_SIZE_GB=8
fi
SWAP_FILE="/swapfile_xingrin"
log_info "系统内存: ${TOTAL_MEM_GB}GB"
log_info "将创建 ${SWAP_SIZE_GB}GB 交换分区: $SWAP_FILE"
# 检查磁盘空间(向下取整,保守估计)
AVAILABLE_GB=$(df / | tail -1 | awk '{printf "%.0f", $4/1024/1024}')
if [ "$AVAILABLE_GB" -lt "$SWAP_SIZE_GB" ]; then
log_error "磁盘空间不足!可用: ${AVAILABLE_GB}GB需要: ${SWAP_SIZE_GB}GB"
exit 1
fi
# 创建交换文件
log_info "正在创建交换文件(可能需要几分钟)..."
dd if=/dev/zero of=$SWAP_FILE bs=1G count=$SWAP_SIZE_GB status=progress
# 设置权限
chmod 600 $SWAP_FILE
# 格式化为交换分区
mkswap $SWAP_FILE
# 启用交换分区
swapon $SWAP_FILE
# 添加到 fstab开机自动挂载
if ! grep -q "$SWAP_FILE" /etc/fstab; then
echo "$SWAP_FILE none swap sw 0 0" >> /etc/fstab
log_info "已添加到 /etc/fstab开机自动启用"
fi
# 优化 swappiness降低交换倾向优先使用内存
SWAPPINESS=10
if ! grep -q "vm.swappiness" /etc/sysctl.conf; then
echo "vm.swappiness=$SWAPPINESS" >> /etc/sysctl.conf
fi
sysctl vm.swappiness=$SWAPPINESS >/dev/null
log_info "交换分区创建成功!"
echo ""
echo "当前交换分区状态:"
swapon --show
echo ""
free -h

View File

@@ -42,10 +42,10 @@ if ! docker info >/dev/null 2>&1; then
exit 1
fi
if command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
elif docker compose version >/dev/null 2>&1; then
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
echo -e "${RED}[ERROR]${NC} 未检测到 docker compose请先安装"
exit 1
@@ -135,6 +135,7 @@ if [ "$DEV_MODE" = true ]; then
fi
else
# 生产模式:拉取 Docker Hub 镜像
# pull 后 up -d 会自动检测镜像变化并重建容器
if [ "$WITH_FRONTEND" = true ]; then
echo -e "${CYAN}[PULL]${NC} 拉取最新镜像..."
${COMPOSE_CMD} ${COMPOSE_ARGS} pull
@@ -173,7 +174,7 @@ if [ "$WITH_FRONTEND" = true ]; then
echo -e " XingRin: ${CYAN}https://${ACCESS_HOST}/${NC}"
echo -e " ${YELLOW}(HTTP 会自动跳转到 HTTPS)${NC}"
else
echo -e " API: ${CYAN}http://${ACCESS_HOST}:8888${NC}"
echo -e " API: ${CYAN}通过前端或 nginx 访问(后端未暴露 8888${NC}"
echo ""
echo -e "${YELLOW}[TIP]${NC} 前端未启动,请手动运行:"
echo " cd frontend && pnpm dev"

View File

@@ -79,20 +79,20 @@ ENV GOPATH=/root/go
ENV PATH=/usr/local/go/bin:$PATH:$GOPATH/bin
ENV GOPROXY=https://goproxy.cn,direct
# 5. 安装 uv超快的 Python 包管理器)
RUN pip install uv --break-system-packages
# 安装 Python 依赖(使用 uv 并行下载,速度快 10-100 倍)
COPY backend/requirements.txt .
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system -r requirements.txt --break-system-packages && \
rm -f /usr/local/lib/python3.*/dist-packages/argparse.py && \
rm -rf /usr/local/lib/python3.*/dist-packages/__pycache__/argparse*
COPY --from=go-builder /usr/local/go /usr/local/go
COPY --from=go-builder /go/bin/* /usr/local/bin/
COPY --from=go-builder /usr/local/bin/massdns /usr/local/bin/massdns
# 5. 安装 uv Python 包管理器)并安装 Python 依赖
COPY backend/requirements.txt .
RUN pip install uv --break-system-packages && \
uv pip install --system -r requirements.txt --break-system-packages && \
rm -f /usr/local/lib/python3.*/dist-packages/argparse.py && \
rm -rf /usr/local/lib/python3.*/dist-packages/__pycache__/argparse* && \
rm -rf /root/.cache/uv && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# 6. 复制后端代码
COPY backend /app/backend
ENV PYTHONPATH=/app/backend

View File

@@ -14,10 +14,9 @@
- **端口要求**: 需要开放以下端口
- `80` - HTTP 访问(自动跳转到 HTTPS
- `443` - HTTPS 访问(主要访问端口)
- `3000` - 前端开发服务(开发模式)
- `8888` - 后端 API 服务
- `5432` - PostgreSQL 数据库(如使用本地数据库)
- `6379` - Redis 缓存服务
- 后端 API 仅容器内监听 8888由 nginx 反代到 80/443对公网无需放行 8888
## 一键安装
@@ -64,10 +63,10 @@ sudo ./install.sh --no-frontend
80 - HTTP 访问
443 - HTTPS 访问
3000 - 前端服务(开发模式)
8888 - 后端 API
5432 - PostgreSQL如使用本地数据库
6379 - Redis 缓存
```
> 后端 API 默认仅在容器内 8888 监听,由 nginx 反代到 80/443对公网无需放行 8888。
#### 推荐方案
- **国外 VPS**:如 Vultr、DigitalOcean、Linode 等,默认开放所有端口,无需额外配置
@@ -157,8 +156,8 @@ DB_USER=postgres # 数据库用户
DB_PASSWORD=随机生成 # 数据库密码
# 服务配置
SERVER_PORT=8888 # 后端服务端口
PUBLIC_HOST=server # 对外访问地址
SERVER_PORT=8888 # 后端容器内部端口(仅 Docker 内网监听)
PUBLIC_HOST=server # 对外访问地址(远程 Worker 用,配置外网 IP 或域名)
DEBUG=False # 调试模式
# 版本配置

View File

@@ -0,0 +1,123 @@
# 扫描流程架构
## 完整扫描流程
```mermaid
flowchart TB
START[Start Scan]
TARGET[Input Target]
START --> TARGET
subgraph STAGE1["Stage 1: Discovery Sequential"]
direction TB
subgraph SUB["Subdomain Discovery"]
direction TB
SUBFINDER[subfinder]
AMASS[amass]
SUBLIST3R[sublist3r]
ASSETFINDER[assetfinder]
MERGE[Merge & Deduplicate]
BRUTEFORCE[puredns bruteforce<br/>Dictionary Attack]
MUTATE[dnsgen + puredns<br/>Mutation Generation]
RESOLVE[puredns resolve<br/>Alive Verification]
SUBFINDER --> MERGE
AMASS --> MERGE
SUBLIST3R --> MERGE
ASSETFINDER --> MERGE
MERGE --> BRUTEFORCE
BRUTEFORCE --> MUTATE
MUTATE --> RESOLVE
end
subgraph PORT["Port Scan"]
NAABU[naabu<br/>Port Discovery]
end
subgraph SITE["Site Scan"]
HTTPX1[httpx<br/>Web Service Detection]
end
RESOLVE --> NAABU
NAABU --> HTTPX1
end
TARGET --> SUBFINDER
TARGET --> AMASS
TARGET --> SUBLIST3R
TARGET --> ASSETFINDER
subgraph STAGE2["Stage 2: Analysis Parallel"]
direction TB
subgraph URL["URL Collection"]
direction TB
WAYMORE[waymore<br/>Historical URLs]
KATANA[katana<br/>Crawler]
URO[uro<br/>URL Deduplication]
HTTPX2[httpx<br/>Alive Verification]
WAYMORE --> URO
KATANA --> URO
URO --> HTTPX2
end
subgraph DIR["Directory Scan"]
FFUF[ffuf<br/>Directory Bruteforce]
end
end
HTTPX1 --> WAYMORE
HTTPX1 --> KATANA
HTTPX1 --> FFUF
subgraph STAGE3["Stage 3: Vulnerability Sequential"]
direction TB
subgraph VULN["Vulnerability Scan"]
direction LR
DALFOX[dalfox<br/>XSS Scan]
NUCLEI[nuclei<br/>Vulnerability Scan]
end
end
HTTPX2 --> DALFOX
HTTPX2 --> NUCLEI
DALFOX --> FINISH
NUCLEI --> FINISH
FFUF --> FINISH
FINISH[Scan Complete]
style START fill:#ff9999
style FINISH fill:#99ff99
style TARGET fill:#ffcc99
style STAGE1 fill:#e6f3ff
style STAGE2 fill:#fff4e6
style STAGE3 fill:#ffe6f0
```
## 执行阶段定义
```python
# backend/apps/scan/configs/command_templates.py
EXECUTION_STAGES = [
{'mode': 'sequential', 'flows': ['subdomain_discovery', 'port_scan', 'site_scan']},
{'mode': 'parallel', 'flows': ['url_fetch', 'directory_scan']},
{'mode': 'sequential', 'flows': ['vuln_scan']},
]
```
## 各阶段输出
| Flow | 工具 | 输出表 |
|------|------|--------|
| subdomain_discovery | subfinder, amass, sublist3r, assetfinder, puredns | Subdomain |
| port_scan | naabu | HostPortMapping |
| site_scan | httpx | WebSite |
| url_fetch | waymore, katana, uro, httpx | Endpoint |
| directory_scan | ffuf | Directory |
| vuln_scan | dalfox, nuclei | Vulnerability |

View File

@@ -148,17 +148,82 @@ sequenceDiagram
2. **远程 Worker**:按需拉取对应版本
3. **自动同步**update.sh 统一更新版本号
## Agent 自动更新机制
### 概述
Agent 是运行在每个 Worker 节点上的轻量级心跳服务(~10MB负责上报节点状态和负载信息。当主服务器更新后Agent 需要同步更新以保持版本一致。
### 版本检测流程
```mermaid
sequenceDiagram
participant A as Agent
participant S as Server
participant H as Docker Hub
A->>S: POST /api/workers/{id}/heartbeat/
Note right of A: {"cpu": 50, "mem": 60, "version": "v1.0.8"}
S->>S: 比较 agent_version vs IMAGE_TAG
alt 版本匹配
S->>A: {"status": "ok", "need_update": false}
else 版本不匹配 (远程 Worker)
S->>S: 设置状态为 updating
S->>A: {"status": "ok", "need_update": true}
S-->>H: SSH: docker pull agent:v1.0.19
S-->>A: SSH: 重启 agent 容器
else 版本不匹配 (本地 Worker)
S->>S: 设置状态为 outdated
S->>A: {"status": "ok", "need_update": true}
Note over S: 需用户手动 ./update.sh
end
```
### Worker 状态流转
| 场景 | 状态变化 | 说明 |
|------|---------|------|
| 首次心跳 | `pending/deploying``online` | Agent 启动成功 |
| 远程 Worker 版本不匹配 | `online``updating``online` | 服务端自动 SSH 更新 |
| 远程 Worker 更新失败 | `updating``outdated` | SSH 执行失败 |
| 本地 Worker 版本不匹配 | `online``outdated` | 需手动 update.sh |
| 版本匹配 | `updating/outdated``online` | 恢复正常 |
### 更新触发条件
1. **远程 Worker**:服务端检测到版本不匹配时,自动通过 SSH 执行更新
2. **本地 Worker**:用户执行 `./update.sh`docker-compose 会拉取新镜像并重启
### 防重复机制
使用 Redis 锁防止同一 Worker 在 60 秒内重复触发更新:
```
lock_key = f"agent_update_lock:{worker_id}"
redis.set(lock_key, "1", nx=True, ex=60)
```
### 相关文件
| 文件 | 作用 |
|------|------|
| `backend/apps/engine/views/worker_views.py` | 心跳 API版本检测和更新触发 |
| `backend/scripts/worker-deploy/agent.sh` | Agent 心跳脚本,上报版本号 |
| `backend/scripts/worker-deploy/start-agent.sh` | Agent 启动脚本 |
| `docker/agent/Dockerfile` | Agent 镜像构建,注入 IMAGE_TAG |
## 开发环境配置
### 本地开发测试
```bash
# docker/.env 中添加
TASK_EXECUTOR_IMAGE=docker-agent:latest # 指向本地构建镜像
# docker/.env 中添加(开发模式会自动设置)
TASK_EXECUTOR_IMAGE=docker-worker:v1.1.0-dev # 指向本地构建镜像
```
### 开发模式启动
```bash
# 使用本地构建镜像
# 使用本地构建镜像(自动构建并标记为 ${VERSION}-dev
./install.sh --dev
./start.sh --dev
```
@@ -188,7 +253,13 @@ else:
TASK_EXECUTOR_IMAGE = ''
```
## 故障排查
## Agent 自动更新机制
### 概述
Agent 是运行在每个 Worker 节点上的轻量级心跳服务负责上报节点状态和负载信息。当主服务器更新后Agent 需要同步更新以保持版本一致。
### 版本检测流程
### 版本不一致问题
**症状**:任务执行失败,兼容性错误
@@ -238,7 +309,8 @@ curl -s https://hub.docker.com/v2/repositories/yyhuni/xingrin-worker/tags/
4. ✅ 使用 `docker system prune` 清理旧镜像
### 开发调试
1. ✅ 本地测试使用 `--dev` 模式
1. ✅ 本地测试使用 `--dev` 模式(自动构建 `docker-worker:${VERSION}-dev`
2. ✅ 远程测试先推送测试版本到 Hub
3. ✅ 生产环境避免使用 `latest` 标签
4. ✅ 版本回滚通过修改 `IMAGE_TAG` 实现
3. ✅ 生产环境避免使用 `latest` 标签,始终使用明确版本号
4.开发环境使用 `-dev` 后缀区分开发版本
5. ✅ 版本回滚通过修改 `IMAGE_TAG` 实现

View File

@@ -40,62 +40,66 @@ Wordlist
- 统计文件大小和行数
- 创建数据库记录
```
┌──────────────────────────────────────────────────────────────────────────┐
│ Server 容器 │
│ │
┌─────────────────────────────────────────────────────────────────┐
前端 UI │ │
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ 上传字典 │ 编辑内容 │ │ 删除字典 │ │ │
│ 选择文件 │ 在线修改 │ │ │ │ │
└──────┬───────┘ └──────┬───────┘ └──────────────┘ │ │
└─────────┼───────────────────┼───────────────────────────────────┘
│ │ │
│ ▼ ▼ │
┌─────────────────────────────────────────────────────────────────┐ │
│ │ WordlistViewSet │ │
│ │ POST /api/wordlists/ | PUT .../content/ │ │
└─────────────────────────────┬───────────────────────────────────┘ │
│ │ │
▼ │
┌─────────────────────────────────────────────────────────────────┐ │
│ │ WordlistService │ │
│ │ │ │
┌────────────────────┐ ┌────────────────────────────────┐ │ │
│ create_wordlist() update_wordlist_content() │ │ │
│ │ │ 创建字典 │ │ 更新字典内容 │ │ │
└────────┬───────────┘ └───────────────┬────────────────┘ │ │
└───────────┼────────────────────────────────┼────────────────────┘ │
│ │ │
▼ ▼ │
┌─────────────────────────────────────────────────────────────────┐ │
│ │ 处理流程 │ │
│ │ │ │
1. 保存文件到 /opt/xingrin/wordlists/<filename> │ │
│ │ 2. 计算 SHA256 哈希值 │ │
│ 3. 统计文件大小和行数 │ │
│ │ 4. 创建/更新数据库记录 │ │
└─────────────────────────────────────────────────────────────────┘ │
│ │
┌─────────────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL 数据库 │ │
│ │ │ │
INSERT INTO wordlist (name, file_path, file_size, │ │
│ │ line_count, file_hash) │ │
VALUES ('subdomains', '/opt/xingrin/wordlists/subdomains.txt', │ │
│ │ 1024000, 50000, 'sha256...') │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
┌─────────────────────────────────────────────────────────────────┐
│ │ 文件系统 │ │
/opt/xingrin/wordlists/ │ │
├── common.txt │ │
├── subdomains.txt │ │
│ │ └── directories.txt │ │
└─────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
```mermaid
flowchart TB
subgraph SERVER["🖥️ Server 容器"]
direction TB
subgraph UI["前端 UI"]
direction LR
UPLOAD["📤 上传字典<br/>选择文件"]
EDIT["✏️ 编辑内容<br/>在线修改"]
DELETE["🗑️ 删除字典"]
end
UPLOAD --> API
EDIT --> API
subgraph API["API 层"]
VIEWSET["WordlistViewSet<br/>POST /api/wordlists/<br/>PUT .../content/"]
end
API --> SERVICE
subgraph SERVICE["业务逻辑层"]
CREATE["create_wordlist()<br/>创建字典"]
UPDATE["update_wordlist_content()<br/>更新字典内容"]
end
CREATE --> PROCESS
UPDATE --> PROCESS
subgraph PROCESS["处理流程"]
direction TB
STEP1["1⃣ 保存文件到<br/>/opt/xingrin/wordlists/"]
STEP2["2⃣ 计算 SHA256 哈希值"]
STEP3["3⃣ 统计文件大小和行数"]
STEP4["4⃣ 创建/更新数据库记录"]
STEP1 --> STEP2
STEP2 --> STEP3
STEP3 --> STEP4
end
STEP4 --> DB
STEP1 --> FS
subgraph DB["💾 PostgreSQL 数据库"]
DBRECORD["INSERT INTO wordlist<br/>name: 'subdomains'<br/>file_path: '/opt/xingrin/wordlists/subdomains.txt'<br/>file_size: 1024000<br/>line_count: 50000<br/>file_hash: 'sha256...'"]
end
subgraph FS["📁 文件系统"]
FILES["/opt/xingrin/wordlists/<br/>├── common.txt<br/>├── subdomains.txt<br/>└── directories.txt"]
end
end
style SERVER fill:#e6f3ff
style UI fill:#fff4e6
style API fill:#f0f0f0
style SERVICE fill:#d4edda
style PROCESS fill:#ffe6f0
style DB fill:#cce5ff
style FS fill:#e2e3e5
```
## 四、Worker 端获取流程
@@ -110,76 +114,65 @@ Worker 执行扫描任务时,通过 `ensure_wordlist_local()` 获取字典:
3. 下载地址:`GET /api/wordlists/download/?wordlist=<name>`
4. 返回本地字典文件路径
```
┌──────────────────────────────────────────────────────────────────────────┐
│ Worker 容器 │
│ │
│ ┌─────────────┐ │
│ │ 扫描任务 │ │
│ │ 需要字典 │ │
│ └──────┬──────┘ │
│ │
▼ │
┌─────────────────────────┐ ┌─────────────────────────────────┐ │
│ ensure_wordlist_local() │ │ PostgreSQL │ │
│ │ 参数: wordlist_name │─────▶│ 查询 Wordlist 表 │ │
│ │ 获取 file_path, file_hash │ │
└───────────┬─────────────┘ └─────────────────────────────────┘ │
│ │ │
▼ │
│ ┌─────────────────────────┐ │
│ │ 检查本地文件是否存在 │ │
│ │ /opt/xingrin/wordlists/ │ │
└───────────┬─────────────┘ │
│ │
┌───────┴───────┐ │
│ │ │ │
▼ ▼ │
┌────────┐ ┌────────────┐ │
│ │ 不存在 │ │ 存在 │ │
└───┬────┘ └─────┬──────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────────┐ │
│ 计算本地文件 SHA256 │ │
│ │ │ 与数据库 hash 比较 │ │
│ └──────────┬──────────┘ │
│ │
│ │ ┌───────┴───────┐ │
│ │ │ │ │
│ │ ▼ ▼ │
│ │ ┌──────────┐ ┌──────────────┐ │
│ │ │ 一致 │ │ 不一致 │ │
│ │ 直接使用 │ │ 需重新下载 │ │
│ │ └────┬─────┘ └───────┬──────┘ │
│ │ │ │
│ │ │ │ │
▼ │ ▼ │
┌─────────────┴─────────────────────────────────────────────────────┐ │
│ │ 从 Server API 下载 │ │
│ │ GET /api/wordlists/download/?wordlist=<name> │ │
│ │ │ │
│ │ ┌──────────┐ HTTP Request ┌──────────────────────┐ │ │
│ │ │ Worker │ ───────────────────────▶│ Server (Django) │ │ │
│ │ │◀─────────────────────── │ 返回文件内容 │ │ │
└──────────┘ File Content └──────────────────────┘ │ │
│ │ │ │
保存到: /opt/xingrin/wordlists/<filename> │ │
└───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
┌─────────────────────────────────────────────────────────────────┐ │
│ │ 返回本地字典文件路径 │ │
│ │ /opt/xingrin/wordlists/subdomains.txt │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 执行扫描工具 │ │
│ │ puredns bruteforce -w /opt/xingrin/wordlists/xxx.txt │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
```mermaid
flowchart TB
subgraph WORKER["🔧 Worker 容器"]
direction TB
START["🎯 扫描任务<br/>需要字典"]
START --> ENSURE
ENSURE["ensure_wordlist_local()<br/>参数: wordlist_name"]
ENSURE --> QUERY
QUERY["📊 查询 PostgreSQL<br/>获取 file_path, file_hash"]
QUERY --> CHECK
CHECK{"🔍 检查本地文件<br/>/opt/xingrin/wordlists/"}
CHECK -->|不存在| DOWNLOAD
CHECK -->|存在| HASH
HASH["🔐 计算本地文件 SHA256<br/>与数据库 hash 比较"]
HASH -->|一致| USE
HASH -->|不一致| DOWNLOAD
DOWNLOAD["📥 从 Server API 下载<br/>GET /api/wordlists/download/?wordlist=name"]
DOWNLOAD --> SERVER
SERVER["🌐 HTTP Request"]
SERVER -.请求.-> API["Server (Django)<br/>返回文件内容"]
API -.响应.-> SERVER
SERVER --> SAVE
SAVE["💾 保存到本地<br/>/opt/xingrin/wordlists/filename"]
SAVE --> RETURN
USE["✅ 直接使用"] --> RETURN
RETURN["📂 返回本地字典文件路径<br/>/opt/xingrin/wordlists/subdomains.txt"]
RETURN --> EXEC
EXEC["🚀 执行扫描工具<br/>puredns bruteforce -w /opt/xingrin/wordlists/xxx.txt"]
end
style WORKER fill:#e6f3ff
style START fill:#fff4e6
style CHECK fill:#ffe6f0
style HASH fill:#ffe6f0
style USE fill:#d4edda
style DOWNLOAD fill:#f8d7da
style RETURN fill:#d4edda
style EXEC fill:#cce5ff
```
## 五、Hash 校验机制
@@ -199,25 +192,28 @@ Worker 执行扫描任务时,通过 `ensure_wordlist_local()` 获取字典:
**注意**Worker 容器只挂载了 `results``logs` 目录,没有挂载 `wordlists` 目录,所以字典文件需要通过 API 下载。
```
Worker本地/远程) Server
│ │
│ 1. 查询数据库获取 file_hash │
│─────────────────────────────────▶│
│ │
│ 2. 检查本地缓存 │
│ - 存在且 hash 匹配 → 直接使用│
│ - 不存在或不匹配 → 继续下载 │
│ │
│ 3. GET /api/wordlists/download/ │
│─────────────────────────────────▶│
│ │
4. 返回文件内容 │
│◀─────────────────────────────────│
5. 保存到本地缓存 │
/opt/xingrin/wordlists/
```mermaid
sequenceDiagram
participant W as Worker (本地/远程)
participant DB as PostgreSQL
participant S as Server API
participant FS as 本地缓存
W->>DB: 1⃣ 查询数据库获取 file_hash
DB-->>W: 返回 file_hash
W->>FS: 2⃣ 检查本地缓存
alt 存在且 hash 匹配
FS-->>W: ✅ 直接使用
else 不存在或不匹配
W->>S: 3⃣ GET /api/wordlists/download/
S-->>W: 4⃣ 返回文件内容
W->>FS: 5⃣ 保存到本地缓存<br/>/opt/xingrin/wordlists/
FS-->>W: ✅ 使用缓存文件
end
Note over W,FS: 本地 Worker 优势:<br/>• 网络延迟更低(容器内网络)<br/>• 缓存可复用(同一宿主机多次任务)
```
### 本地 Worker 的优势
@@ -235,8 +231,8 @@ Worker本地/远程) Server
WORDLISTS_PATH=/opt/xingrin/wordlists
# Server 地址Worker 用于下载文件)
PUBLIC_HOST=your-server-ip
SERVER_PORT=8888
PUBLIC_HOST=your-server-ip # 远程 Worker 会通过 https://{PUBLIC_HOST}/api 访问
SERVER_PORT=8888 # 后端容器内部端口,仅 Docker 内网监听
```
## 八、常见问题
@@ -248,8 +244,8 @@ A: 更新字典内容后会重新计算 hashWorker 下次使用时会检测
### Q: 远程 Worker 下载文件失败?
A: 检查:
1. `PUBLIC_HOST` 是否配置为 Server 的外网 IP
2. Server 端口(默认 8888是否开放
1. `PUBLIC_HOST` 是否配置为 Server 的外网 IP 或域名
2. Nginx 443 (HTTPS) 是否可达(远程 Worker 通过 nginx 访问后端)
3. Worker 到 Server 的网络是否通畅
### Q: 如何批量导入字典?

View File

@@ -29,9 +29,21 @@ import { AuthLayout } from "@/components/auth/auth-layout"
// 定义页面的元数据信息,用于 SEO 优化
export const metadata: Metadata = {
title: "XingRin - 星环", // 页面标题
description: "XingRin - 星环", // 页面描述
generator: "XingRin", // 生成器标识
title: "星环 (Xingrin) - 攻击面管理平台 | ASM",
description: "星环 - 攻击面管理平台 (ASM)提供自动化资产发现、漏洞扫描、子域名枚举、端口扫描等功能。支持分布式扫描、Nuclei 集成、定时任务。",
keywords: ["ASM", "攻击面管理", "漏洞扫描", "资产发现", "Bug Bounty", "渗透测试", "Nuclei", "子域名枚举", "安全工具", "EASM", "安全"],
generator: "Xingrin ASM Platform",
authors: [{ name: "yyhuni" }],
openGraph: {
title: "星环 (Xingrin) - 攻击面管理平台",
description: "攻击面管理平台 (ASM),提供自动化资产发现与漏洞扫描",
type: "website",
locale: "zh_CN",
},
robots: {
index: true,
follow: true,
},
}
// 使用思源黑体 + 系统字体回退,完全本地加载

View File

@@ -1,8 +1,8 @@
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "登录 - XingRin - 星环",
description: "登录到 XingRin - 星环",
title: "登录 - 星环 | 攻击面管理平台",
description: "星环 (XingRin) - 攻击面管理平台 (ASM),提供自动化资产发现与漏洞扫描",
}
/**

View File

@@ -73,10 +73,12 @@ export default function LoginPage() {
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8" onSubmit={handleSubmit}>
<FieldGroup>
{/* 指纹标识 - 用于 FOFA/Shodan 等搜索引擎识别 */}
<meta name="generator" content="Xingrin ASM Platform" />
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">XingRin - </h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<Field>
@@ -124,6 +126,10 @@ export default function LoginPage() {
</div>
</CardContent>
</Card>
{/* 版本号 */}
<p className="text-center text-xs text-muted-foreground mt-4">
{process.env.NEXT_PUBLIC_VERSION || 'dev'}
</p>
</div>
</div>
</div>

View File

@@ -88,9 +88,9 @@ export function createDirectoryColumns({
// URL 列
{
accessorKey: "url",
size: 300,
size: 400,
minSize: 200,
maxSize: 400,
maxSize: 500,
header: ({ column }) => {
return (
<Button

View File

@@ -78,21 +78,21 @@ export function createIPAddressColumns(params: {
enableSorting: false,
enableHiding: false,
},
// IP 地址
// IP 列
{
accessorKey: "ip",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="IP 地址" />
<DataTableColumnHeader column={column} title="IP Address" />
),
cell: ({ row }) => (
<TruncatedCell value={row.original.ip} maxLength="ip" mono />
),
},
// 关联主机名
// host
{
accessorKey: "hosts",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="关联主机" />
<DataTableColumnHeader column={column} title="Hosts" />
),
cell: ({ getValue }) => {
const hosts = getValue<string[]>()
@@ -107,22 +107,38 @@ export function createIPAddressColumns(params: {
return (
<div className="flex flex-col gap-1">
{displayHosts.map((host, index) => (
<span key={index} className="text-sm font-mono">{host}</span>
<TruncatedCell key={index} value={host} maxLength="host" mono />
))}
{hasMore && (
<Badge variant="secondary" className="text-xs w-fit">
+{hosts.length - 3} more
</Badge>
<Popover>
<PopoverTrigger asChild>
<Badge variant="secondary" className="text-xs w-fit cursor-pointer hover:bg-muted">
+{hosts.length - 3} more
</Badge>
</PopoverTrigger>
<PopoverContent className="w-80 p-3">
<div className="space-y-2">
<h4 className="font-medium text-sm">All Hosts ({hosts.length})</h4>
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto">
{hosts.map((host, index) => (
<span key={index} className="text-sm font-mono break-all">
{host}
</span>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
)
},
},
// 发现时间
// discoveredAt
{
accessorKey: "discoveredAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="发现时间" />
<DataTableColumnHeader column={column} title="Discovered At" />
),
cell: ({ getValue }) => {
const value = getValue<string | undefined>()
@@ -133,7 +149,7 @@ export function createIPAddressColumns(params: {
{
accessorKey: "ports",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="开放端口" />
<DataTableColumnHeader column={column} title="Open Ports" />
),
cell: ({ getValue }) => {
const ports = getValue<number[]>()
@@ -186,12 +202,12 @@ export function createIPAddressColumns(params: {
<Popover>
<PopoverTrigger asChild>
<Badge variant="outline" className="text-xs cursor-pointer hover:bg-muted">
+{ports.length - 8}
+{ports.length - 8} more
</Badge>
</PopoverTrigger>
<PopoverContent className="w-80 p-3">
<div className="space-y-2">
<h4 className="font-medium text-sm"> ({sortedPorts.length})</h4>
<h4 className="font-medium text-sm">All Open Ports ({sortedPorts.length})</h4>
<div className="flex flex-wrap gap-1 max-h-32 overflow-y-auto">
{sortedPorts.map((port, index) => (
<Badge

View File

@@ -267,7 +267,7 @@ export const createTargetColumns = ({
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="目标名称" />
<DataTableColumnHeader column={column} title="Target Name" />
),
cell: ({ row }) => (
<TargetNameCell
@@ -282,7 +282,7 @@ export const createTargetColumns = ({
{
accessorKey: "type",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="类型" />
<DataTableColumnHeader column={column} title="Type" />
),
cell: ({ row }) => {
const type = row.getValue("type") as string | null

View File

@@ -188,7 +188,7 @@ export const createEngineColumns = ({
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="引擎名称" />
<DataTableColumnHeader column={column} title="Engine Name" />
),
cell: ({ row }) => {
const name = row.getValue("name") as string

View File

@@ -180,7 +180,7 @@ export const createScheduledScanColumns = ({
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="任务名称" />
<DataTableColumnHeader column={column} title="Task Name" />
),
cell: ({ row }) => {
const name = row.getValue("name") as string
@@ -216,7 +216,7 @@ export const createScheduledScanColumns = ({
{
accessorKey: "engineName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="扫描引擎" />
<DataTableColumnHeader column={column} title="Scan Engine" />
),
cell: ({ row }) => {
const engineName = row.getValue("engineName") as string
@@ -231,7 +231,7 @@ export const createScheduledScanColumns = ({
// Cron 表达式列
{
accessorKey: "cronExpression",
header: "调度时间",
header: "Cron Expression",
cell: ({ row }) => {
const cron = row.original.cronExpression
return (
@@ -251,7 +251,7 @@ export const createScheduledScanColumns = ({
// 目标列(根据 scanMode 显示组织或目标)
{
accessorKey: "scanMode",
header: "目标",
header: "Target",
cell: ({ row }) => {
const scanMode = row.original.scanMode
const organizationName = row.original.organizationName
@@ -283,7 +283,7 @@ export const createScheduledScanColumns = ({
{
accessorKey: "isEnabled",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="状态" />
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const isEnabled = row.getValue("isEnabled") as boolean
@@ -308,7 +308,7 @@ export const createScheduledScanColumns = ({
{
accessorKey: "nextRunTime",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="下次执行" />
<DataTableColumnHeader column={column} title="Next Run" />
),
cell: ({ row }) => {
const nextRunTime = row.getValue("nextRunTime") as string | undefined
@@ -324,7 +324,7 @@ export const createScheduledScanColumns = ({
{
accessorKey: "runCount",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="执行次数" />
<DataTableColumnHeader column={column} title="Run Count" />
),
cell: ({ row }) => {
const count = row.getValue("runCount") as number
@@ -338,7 +338,7 @@ export const createScheduledScanColumns = ({
{
accessorKey: "lastRunTime",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="上次执行" />
<DataTableColumnHeader column={column} title="Last Run" />
),
cell: ({ row }) => {
const lastRunTime = row.getValue("lastRunTime") as string | undefined

View File

@@ -9,7 +9,7 @@ import { useSystemLogs } from "@/hooks/use-system-logs"
export function SystemLogsView() {
const { theme } = useTheme()
const { data } = useSystemLogs({ lines: 200 })
const { data } = useSystemLogs({ lines: 500 })
const content = useMemo(() => data?.content ?? "", [data?.content])

View File

@@ -297,6 +297,8 @@ export function DeployTerminalDialog({
{isConnected && currentStatus === 'deploying' && '正在部署中,点击查看进度'}
{isConnected && currentStatus === 'online' && '节点运行正常'}
{isConnected && currentStatus === 'offline' && '节点离线,可尝试重新部署'}
{isConnected && currentStatus === 'updating' && '正在自动更新 Agent...'}
{isConnected && currentStatus === 'outdated' && '版本过低,需要更新'}
</div>
{/* 右侧:操作按钮 */}
@@ -334,6 +336,28 @@ export function DeployTerminalDialog({
</button>
)}
{/* 更新中 -> 显示"查看进度" */}
{currentStatus === 'updating' && (
<button
onClick={handleAttach}
className="inline-flex items-center px-3 py-1.5 text-sm rounded-md bg-[#e0af68] text-[#1a1b26] hover:bg-[#e0af68]/80 transition-colors"
>
<IconEye className="mr-1.5 h-4 w-4" />
</button>
)}
{/* 版本过低 -> 显示"重新部署" */}
{currentStatus === 'outdated' && (
<button
onClick={handleDeploy}
className="inline-flex items-center px-3 py-1.5 text-sm rounded-md bg-[#f7768e] text-[#1a1b26] hover:bg-[#f7768e]/80 transition-colors"
>
<IconRocket className="mr-1.5 h-4 w-4" />
</button>
)}
{/* 已部署(online/offline) -> 显示"重新部署"和"卸载" */}
{(currentStatus === 'online' || currentStatus === 'offline') && (
<>

View File

@@ -51,6 +51,8 @@ const STATUS_MAP: Record<WorkerStatus, 'online' | 'offline' | 'maintenance' | 'd
offline: 'offline',
pending: 'maintenance',
deploying: 'degraded',
updating: 'degraded',
outdated: 'offline',
}
// 状态中文标签
@@ -59,6 +61,8 @@ const STATUS_LABEL: Record<WorkerStatus, string> = {
offline: '离线',
pending: '等待部署',
deploying: '部署中',
updating: '更新中',
outdated: '版本过低',
}
// 统计卡片组件

View File

@@ -100,7 +100,7 @@ export const createSubdomainColumns = ({
{
accessorKey: "discoveredAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="发现时间" />
<DataTableColumnHeader column={column} title="Discovered At" />
),
cell: ({ getValue }) => {
const value = getValue<string | undefined>()

View File

@@ -95,7 +95,7 @@ export const commandColumns: ColumnDef<Command>[] = [
{
accessorKey: "displayName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="名称" />
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => {
const displayName = row.getValue("displayName") as string
@@ -136,7 +136,7 @@ export const commandColumns: ColumnDef<Command>[] = [
{
accessorKey: "tool",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="所属工具" />
<DataTableColumnHeader column={column} title="Tool" />
),
cell: ({ row }) => {
const tool = row.original.tool
@@ -156,7 +156,7 @@ export const commandColumns: ColumnDef<Command>[] = [
{
accessorKey: "commandTemplate",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="命令模板" />
<DataTableColumnHeader column={column} title="Command Template" />
),
cell: ({ row }) => {
const template = row.getValue("commandTemplate") as string
@@ -192,7 +192,7 @@ export const commandColumns: ColumnDef<Command>[] = [
{
accessorKey: "description",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="描述" />
<DataTableColumnHeader column={column} title="Description" />
),
cell: ({ row }) => {
const description = row.getValue("description") as string
@@ -217,7 +217,7 @@ export const commandColumns: ColumnDef<Command>[] = [
{
accessorKey: "updatedAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="更新时间" />
<DataTableColumnHeader column={column} title="Updated At" />
),
cell: ({ row }) => (
<div className="text-sm text-muted-foreground">

View File

@@ -14,7 +14,7 @@ import { cn } from "@/lib/utils"
* 预设的截断长度配置
*/
export const TRUNCATE_LENGTHS = {
url: 35,
url: 50,
title: 25,
location: 20,
webServer: 20,
@@ -142,7 +142,7 @@ export function TruncatedUrlCell({
: value
return (
<div className="flex items-center gap-1 w-[280px] min-w-[280px]">
<div className="flex items-center gap-1 w-[380px] min-w-[380px]">
<span className={cn("text-sm font-mono truncate", className)}>
{displayText}
</span>

View File

@@ -81,7 +81,7 @@ export function createVulnerabilityColumns({
},
{
accessorKey: "vulnType",
header: "类型",
header: "Vuln Type",
cell: ({ row }) => {
const vulnType = row.getValue("vulnType") as string
const vulnerability = row.original
@@ -143,7 +143,7 @@ export function createVulnerabilityColumns({
},
{
accessorKey: "discoveredAt",
header: "发现时间",
header: "Discovered At",
cell: ({ row }) => {
const discoveredAt = row.getValue("discoveredAt") as string
return (

View File

@@ -62,7 +62,7 @@ export function useUpdateNucleiRepo() {
mutationFn: (data: {
id: number
repoUrl?: string
}) => nucleiRepoApi.updateRepo(data.id, data),
}) => nucleiRepoApi.updateRepo(data.id, { repoUrl: data.repoUrl }),
onSuccess: (_data, variables) => {
toast.success("仓库配置已更新")
queryClient.invalidateQueries({ queryKey: ["nuclei-repos"] })

View File

@@ -75,9 +75,9 @@ export const nucleiRepoApi = {
return response.data
},
/** 更新仓库 */
/** 更新仓库(部分更新) */
updateRepo: async (repoId: number, payload: UpdateRepoPayload): Promise<NucleiRepoResponse> => {
const response = await api.put<NucleiRepoResponse>(`${BASE_URL}${repoId}/`, payload)
const response = await api.patch<NucleiRepoResponse>(`${BASE_URL}${repoId}/`, payload)
return response.data
},

View File

@@ -3,7 +3,7 @@
*/
// Worker 状态枚举(前后端统一)
export type WorkerStatus = 'pending' | 'deploying' | 'online' | 'offline'
export type WorkerStatus = 'pending' | 'deploying' | 'online' | 'offline' | 'updating' | 'outdated'
// Worker 节点
export interface WorkerNode {

View File

@@ -75,7 +75,12 @@ fi
# 获取真实用户(通过 sudo 运行时 $SUDO_USER 是真实用户)
REAL_USER="${SUDO_USER:-$USER}"
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
# macOS 没有 getent使用 dscl 或 ~$USER 替代
if command -v getent &>/dev/null; then
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
else
REAL_HOME=$(eval echo "~$REAL_USER")
fi
# 项目根目录
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -110,13 +115,22 @@ generate_random_string() {
fi
}
# 跨平台 sed -i兼容 macOS 和 Linux
sed_inplace() {
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "$@"
else
sed -i "$@"
fi
}
# 更新 .env 文件中的某个键
update_env_var() {
local file="$1"
local key="$2"
local value="$3"
if grep -q "^$key=" "$file"; then
sed -i -e "s|^$key=.*|$key=$value|" "$file"
sed_inplace "s|^$key=.*|$key=$value|" "$file"
else
echo "$key=$value" >> "$file"
fi
@@ -126,7 +140,7 @@ update_env_var() {
GENERATED_DB_PASSWORD=""
GENERATED_DJANGO_KEY=""
# 生成自签 HTTPS 证书(无域名场景
# 生成自签 HTTPS 证书(使用容器,避免宿主机 openssl 兼容性问题
generate_self_signed_cert() {
local ssl_dir="$DOCKER_DIR/nginx/ssl"
local fullchain="$ssl_dir/fullchain.pem"
@@ -139,14 +153,18 @@ generate_self_signed_cert() {
info "未检测到 HTTPS 证书正在生成自签证书localhost..."
mkdir -p "$ssl_dir"
if openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
-keyout "$privkey" \
-out "$fullchain" \
# 使用容器生成证书,避免依赖宿主机 openssl 版本
if docker run --rm -v "$ssl_dir:/ssl" alpine/openssl \
req -x509 -nodes -newkey rsa:2048 -days 365 \
-keyout /ssl/privkey.pem \
-out /ssl/fullchain.pem \
-subj "/C=CN/ST=NA/L=NA/O=XingRin/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" >/dev/null 2>&1; then
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
>/dev/null 2>&1; then
success "自签证书已生成: $ssl_dir"
else
warn "自签证书生成失败,请检查 openssl 是否可用,或手动放置证书到 $ssl_dir"
warn "自签证书生成失败,请手动放置证书到 $ssl_dir"
fi
}
@@ -215,7 +233,7 @@ show_summary() {
echo -e "${YELLOW}[!] 云服务器某些厂商默认开启了安全策略(阿里云/腾讯云/华为云等):${RESET}"
echo -e " 端口未放行可能导致无法访问或无法扫描强烈推荐用国外vps或者在云控制台放行"
echo -e " ${RESET}80, 443, 3000,8888, 5432, 6379"
echo -e " ${RESET}80, 443, 5432, 6379"
echo
}
@@ -225,7 +243,7 @@ show_summary() {
step "[1/3] 检查基础命令"
MISSING_CMDS=()
for cmd in git curl jq openssl; do
for cmd in git curl; do
if ! command -v "$cmd" >/dev/null 2>&1; then
MISSING_CMDS+=("$cmd")
warn "未安装: $cmd"
@@ -260,6 +278,46 @@ else
success "docker compose 安装完成"
fi
# ==============================================================================
# 交换分区配置(仅 Linux
# ==============================================================================
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
# 获取当前内存大小GB四舍五入
TOTAL_MEM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}')
TOTAL_MEM_GB=$(awk "BEGIN {printf \"%.0f\", $TOTAL_MEM_KB / 1024 / 1024}")
# 获取当前交换分区大小GB四舍五入
CURRENT_SWAP_KB=$(grep SwapTotal /proc/meminfo | awk '{print $2}')
CURRENT_SWAP_GB=$(awk "BEGIN {printf \"%.0f\", $CURRENT_SWAP_KB / 1024 / 1024}")
# 推荐交换分区大小与内存相同最小1G最大8G
RECOMMENDED_SWAP=$TOTAL_MEM_GB
[ "$RECOMMENDED_SWAP" -lt 1 ] && RECOMMENDED_SWAP=1
[ "$RECOMMENDED_SWAP" -gt 8 ] && RECOMMENDED_SWAP=8
echo ""
info "系统内存: ${TOTAL_MEM_GB}GB当前交换分区: ${CURRENT_SWAP_GB}GB"
# 如果交换分区小于推荐值,提示用户
if [ "$CURRENT_SWAP_GB" -lt "$RECOMMENDED_SWAP" ]; then
echo -n -e "${BOLD}${CYAN}[?] 是否开启 ${RECOMMENDED_SWAP}GB 交换分区?可提升扫描稳定性 (Y/n) ${RESET}"
read -r setup_swap
echo
if [[ ! $setup_swap =~ ^[Nn]$ ]]; then
info "正在配置 ${RECOMMENDED_SWAP}GB 交换分区..."
if bash "$ROOT_DIR/docker/scripts/setup-swap.sh" "$RECOMMENDED_SWAP"; then
success "交换分区配置完成"
else
warn "交换分区配置失败,继续安装..."
fi
else
info "跳过交换分区配置"
fi
else
success "交换分区已足够: ${CURRENT_SWAP_GB}GB"
fi
fi
step "[3/3] 初始化配置"
DOCKER_DIR="$ROOT_DIR/docker"
if [ ! -d "$DOCKER_DIR" ]; then
@@ -353,10 +411,10 @@ if [ -f "$DOCKER_DIR/.env.example" ]; then
-c "CREATE DATABASE $prefect_db;" 2>/dev/null || true
success "数据库准备完成"
sed -i "s/^DB_HOST=.*/DB_HOST=$db_host/" "$DOCKER_DIR/.env"
sed -i "s/^DB_PORT=.*/DB_PORT=$db_port/" "$DOCKER_DIR/.env"
sed -i "s/^DB_USER=.*/DB_USER=$db_user/" "$DOCKER_DIR/.env"
sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=$db_password/" "$DOCKER_DIR/.env"
sed_inplace "s/^DB_HOST=.*/DB_HOST=$db_host/" "$DOCKER_DIR/.env"
sed_inplace "s/^DB_PORT=.*/DB_PORT=$db_port/" "$DOCKER_DIR/.env"
sed_inplace "s/^DB_USER=.*/DB_USER=$db_user/" "$DOCKER_DIR/.env"
sed_inplace "s/^DB_PASSWORD=.*/DB_PASSWORD=$db_password/" "$DOCKER_DIR/.env"
success "已配置远程数据库: $db_user@$db_host:$db_port"
else
info "使用本地 PostgreSQL 容器"
@@ -396,11 +454,28 @@ DOCKER_USER=$(grep "^DOCKER_USER=" "$DOCKER_DIR/.env" 2>/dev/null | cut -d= -f2)
DOCKER_USER=${DOCKER_USER:-yyhuni}
WORKER_IMAGE="${DOCKER_USER}/xingrin-worker:${APP_VERSION}"
info "正在拉取: $WORKER_IMAGE"
if docker pull "$WORKER_IMAGE"; then
success "Worker 镜像拉取完成"
# 开发模式下构建本地 worker 镜像
if [ "$DEV_MODE" = true ]; then
info "开发模式:构建本地 Worker 镜像..."
if docker compose -f "$DOCKER_DIR/docker-compose.dev.yml" build worker; then
# 设置 TASK_EXECUTOR_IMAGE 环境变量指向本地构建的镜像(使用版本号-dev标识
update_env_var "$DOCKER_DIR/.env" "TASK_EXECUTOR_IMAGE" "docker-worker:${APP_VERSION}-dev"
success "本地 Worker 镜像构建完成: docker-worker:${APP_VERSION}-dev"
else
error "开发模式下本地 Worker 镜像构建失败!"
error "请检查构建错误并修复后重试"
exit 1
fi
else
warn "Worker 镜像拉取失败,扫描时会自动重试拉取"
info "正在拉取: $WORKER_IMAGE"
if docker pull "$WORKER_IMAGE"; then
success "Worker 镜像拉取完成"
else
error "Worker 镜像拉取失败,无法继续安装"
error "请检查网络连接或 Docker Hub 访问权限"
error "镜像地址: $WORKER_IMAGE"
exit 1
fi
fi
# ==============================================================================

View File

@@ -80,12 +80,12 @@ if [[ $ans_stop =~ ^[Yy]$ ]]; then
# 先强制停止并删除可能占用网络的容器xingrin-agent 等)
docker rm -f xingrin-agent xingrin-watchdog 2>/dev/null || true
# 停止两种模式的容器
# 停止两种模式的容器(不带 -vvolume 在第 5 步单独处理)
[ -f "docker-compose.yml" ] && ${COMPOSE_CMD} -f docker-compose.yml down 2>/dev/null || true
[ -f "docker-compose.dev.yml" ] && ${COMPOSE_CMD} -f docker-compose.dev.yml down 2>/dev/null || true
# 手动删除网络(以防 compose 未能删除)
docker network rm xingrin_network 2>/dev/null || true
docker network rm xingrin_network docker_default 2>/dev/null || true
success "容器和网络已停止/删除(如存在)。"
else
@@ -156,19 +156,28 @@ ans_db=${ans_db:-Y}
if [[ $ans_db =~ ^[Yy]$ ]]; then
info "尝试删除与 XingRin 相关的 Postgres 容器和数据卷..."
# docker-compose 项目名为 docker常见资源名如下忽略不存在的情况
# - 容器: docker-postgres-1
# - 数据卷: docker_postgres_data对应 compose 中的 postgres_data 卷)
docker rm -f docker-postgres-1 2>/dev/null || true
docker volume rm docker_postgres_data 2>/dev/null || true
success "本地 Postgres 容器及数据卷已尝试删除(不存在会自动忽略)。"
# 删除可能的容器名(不同 compose 版本命名不同)
docker rm -f docker-postgres-1 xingrin-postgres postgres 2>/dev/null || true
# 删除可能的 volume 名(取决于项目名和 compose 配置)
# 先列出要删除的 volume
for vol in postgres_data docker_postgres_data xingrin_postgres_data; do
if docker volume inspect "$vol" >/dev/null 2>&1; then
if docker volume rm "$vol" 2>/dev/null; then
success "已删除 volume: $vol"
else
warn "无法删除 volume: $vol(可能正在被使用,请先停止所有容器)"
fi
fi
done
success "本地 Postgres 数据卷清理完成。"
else
warn "已保留本地 Postgres 容器和 volume。"
fi
step "[6/6] 是否删除与 XingRin 相关的 Docker 镜像?(y/N)"
step "[6/6] 是否删除与 XingRin 相关的 Docker 镜像?(Y/n)"
read -r ans_images
ans_images=${ans_images:-N}
ans_images=${ans_images:-Y}
if [[ $ans_images =~ ^[Yy]$ ]]; then
info "正在删除 Docker 镜像..."
@@ -199,9 +208,29 @@ if [[ $ans_images =~ ^[Yy]$ ]]; then
fi
docker rmi redis:7-alpine 2>/dev/null || true
# 删除本地构建的开发镜像
docker rmi docker-server docker-frontend docker-nginx docker-agent docker-worker 2>/dev/null || true
docker rmi "docker-worker:${IMAGE_TAG}-dev" 2>/dev/null || true
success "Docker 镜像已删除(如存在)。"
else
warn "已保留 Docker 镜像。"
fi
# 清理构建缓存(可选,会导致下次构建变慢)
echo ""
echo -n -e "${BOLD}${CYAN}[?] 是否清理 Docker 构建缓存?(y/N) ${RESET}"
echo -e "${YELLOW}(清理后下次构建会很慢,一般不需要)${RESET}"
read -r ans_cache
ans_cache=${ans_cache:-N}
if [[ $ans_cache =~ ^[Yy]$ ]]; then
info "清理 Docker 构建缓存..."
docker builder prune -af 2>/dev/null || true
success "构建缓存已清理。"
else
warn "已保留构建缓存(推荐)。"
fi
success "卸载流程已完成。"

View File

@@ -18,6 +18,15 @@
cd "$(dirname "$0")"
# 跨平台 sed -i兼容 macOS 和 Linux
sed_inplace() {
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "$@"
else
sed -i "$@"
fi
}
# 解析参数判断模式
DEV_MODE=false
for arg in "$@"; do
@@ -92,7 +101,7 @@ if [ -f "VERSION" ]; then
if [ -n "$NEW_VERSION" ]; then
# 更新 .env 中的 IMAGE_TAG所有节点将使用此版本的镜像
if grep -q "^IMAGE_TAG=" "docker/.env"; then
sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=$NEW_VERSION/" "docker/.env"
sed_inplace "s/^IMAGE_TAG=.*/IMAGE_TAG=$NEW_VERSION/" "docker/.env"
echo -e " ${GREEN}+${NC} 版本同步: IMAGE_TAG=$NEW_VERSION"
else
echo "IMAGE_TAG=$NEW_VERSION" >> "docker/.env"