07 · 桌面端、靶场与部署#
本篇回答:除了命令行,项目还能怎么跑?靶场怎么起?容器怎么部署?CI 门禁盯的是什么?测试怎么执行?
本章覆盖四类"运行形态":桌面端(Tauri)、靶场(lab/)、容器部署(Docker)、CI 与测试。
1. 四种运行形态总览#
| 形态 | 入口 | 依赖 | 适用场景 |
|---|---|---|---|
| CLI | main.py | pip install . | 日常扫描、CI 集成 |
| Web API | main.py --serve | pip install ".[serve]" | 团队共用、被其它系统调用 |
| 桌面端 | desktop/(Tauri 壳) | 发布版自带引擎 | 非技术用户一键使用 |
| 容器 | Dockerfile / docker-compose.yml | Docker | 批量部署、靶场联调 |
四者共用同一套 core/ 引擎——桌面端与容器只是换了个进程外壳,不存在第二份扫描逻辑。
2. 桌面端:Tauri 壳 + 内嵌引擎#
2.1 设计目标#
单 exe、双击即用、零 Python 环境依赖。做法是把 PyInstaller 打出的引擎编译期嵌入 Rust 壳,运行时自解压后拉起。
目录结构:
desktop/
├── src/ # React 18 + Vite + Tailwind 前端
├── src-tauri/ # Rust 壳(Tauri 2)
│ ├── build.rs # 编译期嵌入引擎
│ ├── src/lib.rs # 运行时:自解压 + 拉起 + JobObject 回收
│ └── tauri.conf.json
├── engine/ # PyInstaller 打包脚本与产物(dist/ruoyi-scan-engine.exe)
├── package.json / vite.config.ts / tsconfig.json
└── make_icon.py / app-icon.png
2.2 编译期嵌入:build.rs#
build.rs 只有 25 行,逻辑是"有引擎就复制,没有就写 0 字节占位":
engine/dist/ruoyi-scan-engine.exe
│ 存在 → std::fs::copy
▼
$OUT_DIR/embedded_engine.bin ← lib.rs 用 include_bytes! 固定路径读取
- 有引擎:复制到
OUT_DIR,打印cargo:warning=嵌入引擎 xxx (N MB)。 - 无引擎:写入
b""(0 字节),提示"开发模式构建(不嵌入引擎)"。 cargo:rerun-if-changed指向引擎文件,保证引擎换了会重新触发构建。
这样开发者本地 cargo tauri dev 无需先构建引擎(会自动回退 Python),而 CI/发布流水线先跑引擎打包再构建壳,得到单文件 exe。
2.3 运行时:引擎自解压#
lib.rs 中:
const API_PORT: u16 = 8123;
static EMBEDDED_ENGINE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/embedded_engine.bin"));
materialize_engine() 的流程与三个关键设计:
| 步骤 | 实现 | 为什么 |
|---|---|---|
| 判断是否需解压 | 以「字节数 + 简单校验和」写入 .engine-stamp,与当前不匹配才解压 | 壳升级换引擎时自动覆盖旧产物;同版本启动零解压开销 |
| 解压目录 | Windows %LOCALAPPDATA%\Ruoyi-Scan\engine\,其它平台 ~/.ruoyi-scan/engine/ | 用户目录可写,不触发 UAC |
| 原子写入 | 先写 .engine.tmp 再 rename | 写入中断不会留下残缺引擎 |
| 被占用时 | rename 失败且 8123 端口已开 → 复用现有引擎 | 引擎正在运行、文件被锁定时仍能启动 |
| 0 字节判断 | EMBEDDED_ENGINE.is_empty() → 返回 None | 开发模式信号,触发 Python 回退 |
2.4 进程回收:Windows JobObject#
壳进程崩溃或被任务管理器强杀时,必须保证引擎进程树被回收,否则 8123 端口被孤儿进程长期占用。
tie_to_job() 用 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 解决:
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(job, JobObjectExtendedLimitInformation, ...);
AssignProcessToJobObject(job, child.as_raw_handle()); // 失败重试 3 次
三个实现细节值得注意:
- Job 句柄刻意不
CloseHandle——它必须随壳进程存活,壳退出时由内核关闭句柄并触发KILL_ON_JOB_CLOSE。源码注释明确写着「句柄泄漏在此处是设计意图,不是缺陷」。 - 挂接失败重试 3 次(每次间隔 50ms)——子进程可能已先挂进别的 Job(嵌套限制),瞬时失败可被吸收。
write_job_diag()落盘诊断——release 版壳是 GUI 子系统,stderr 不可见,只能写文件。- 非 Windows 平台
tie_to_job是空实现(返回false),靠RunEvent::Exit时显式child.kill() + child.wait()兜底。
2.5 拉起后端:spawn_backend()#
port_open(8123)? ──是──→ 跳过拉起,直接连已有服务
│否
▼
materialize_engine() ──Some(exe)──→ 拉起引擎 exe ← 发布路径
│None
▼
python main.py --serve ← 开发回退(RUOYI_SCAN_PYTHON 可覆盖解释器)
两种路径的启动参数完全一致:
- 只绑
127.0.0.1:桌面端后端不对外暴露。 - CORS 白名单固定四项:
http://tauri.localhost、https://tauri.localhost、http://localhost:5173、http://127.0.0.1:5173(后者供vite dev调试)。 --no-cta:桌面端不显示 CLI 的 Star 引导。
子进程句柄存在 Tauri 的 BackendChild(Mutex<Option<Child>>) 里,RunEvent::Exit 时取出并 kill。
2.6 前端结构#
desktop/src/ 按职责分层:
| 目录 | 内容 |
|---|---|
views/ | 六个主视图:Overview、LiveScan、Reports、VulnDb、Assets、Settings |
components/ | Sidebar、Topbar、NewScanDialog、SchedulePanel、charts/、icons/、ui/ |
state/ | AppCtx / PersonaCtx 上下文、useScanConsole(WS 事件消费)、personalize、personaHooks、widgets/、visual/ |
services/backend.ts | 后端 HTTP + WebSocket 客户端封装 |
data/mock.ts | 离线/演示数据 |
persona.ts / persona.css / tokens.ts | 主题与角色定制(含 10 个头像资源) |
styles/theme.css | 全局样式基线 |
前端不直接扫描,全部经 services/backend.ts → 本地引擎的 REST/WS 接口。
3. 靶场:lab/#
靶场是误报红线的物理保障——每个插件必须有"vuln 模式命中 / safe 模式零命中"的证据。
3.1 文件构成#
| 路径 | 作用 |
|---|---|
| lab/server.py | 若依签名靶场(Flask),覆盖 ruoyi 插件包的签名路径 |
| lab/spring_server.py | Spring 签名靶场 |
lab/run_acceptance.py / .sh | 验收驱动脚本 |
lab/real-spring/ | 真实 Spring 靶场(纯 Flask 模拟,CI 自动跑) |
lab/real-ruoyi/ | 真实 RuoYi(Java + MySQL + Maven,源码不入 git,体积过大) |
lab/fp_lab/ | 误报测试语料 |
lab/version_matrix/ | 版本感知路由的验证矩阵 |
lab/Dockerfile | 靶场镜像(SERVER_FILE 选择哪个 server) |
REAL-RUOYI.md / REAL-SPRING.md / VULN-REINTRODUCE.md | 复现与"漏洞重新引入"操作手册 |
3.2 双模式开关#
靶场由环境变量驱动,同一份代码两种姿态:
| 变量 | 取值 | 含义 |
|---|---|---|
SERVER_FILE | server.py / spring_server.py | 选靶场实现 |
LAB_MODE | vuln | 带洞,扫描器应全部 CONFIRMED |
LAB_MODE | safe | 已修复,扫描器必须 0 命中(误报红线) |
LAB_PORT | 端口 | 监听端口(默认 8080 / 8091) |
LAB_HOST | 绑定地址 | 容器内用 0.0.0.0,本机如需隔离可设 127.0.0.1 |
safe 模式的存在不是为了"跑通",而是为了让误报在 CI 里变成一个会红的 job。
4. 容器部署#
4.1 Dockerfile:两阶段构建#
Dockerfile 结构:
阶段 1 builder python:3.11-slim + gcc
venv 建在 /opt/venv → pip install -r requirements.txt(利用层缓存)
│ COPY --from=builder /opt/venv
▼
阶段 2 runtime python:3.11-slim
非 root 用户 scanner(nologin shell)
PYTHONUNBUFFERED=1 / PYTHONDONTWRITEBYTECODE=1
ENTRYPOINT ["python", "main.py"] CMD ["-h"]
要点:
ENTRYPOINT固定为main.py,CMD只提供默认参数——所以docker run ruoyi-scan -p http://target/追加的参数会正确落到 CLI。- 非 root 用户
scanner,并以--chown=scanner:scanner复制源码,/app/reports、/app/data预先 chown(非 root 下可写)。 - 先 COPY requirements.txt 再装依赖,改代码不失效依赖层缓存。
4.2 docker-compose:六服务#
| 服务 | 镜像/构建 | 端口 | 说明 |
|---|---|---|---|
scanner | build: . | — | CLI 形态,command: -h,运行扫描用 docker compose run --rm scanner ... |
api | build: . | 8000:8000 | --serve --host 0.0.0.0 --port 8000,挂载 ./data(SQLite)与 ./reports |
lab-ruoyi | build: ./lab | 127.0.0.1:8080:8080 | 若依靶场,LAB_MODE=vuln |
lab-spring | build: ./lab | 127.0.0.1:8091:8091 | Spring 靶场,LAB_MODE=vuln |
prometheus | 官方镜像 | 127.0.0.1:9090:9090 | profiles: ["monitor"] |
grafana | 官方镜像 | 127.0.0.1:3000:3000 | profiles: ["monitor"] |
4.3 网络与安全收口#
两个独立网络,避免监控栈与靶场互通:
default:scanner/api/ 两个靶场互通monitor:api/prometheus/grafana互通(api同时挂两个网络)
端口绑定是本文件最值得注意的安全设计:
lab-ruoyi: "127.0.0.1:8080:8080" # 带洞靶场绝不暴露到局域网
lab-spring: "127.0.0.1:8091:8091"
prometheus: "127.0.0.1:9090:9090"
grafana: "127.0.0.1:3000:3000"
容器内 LAB_HOST=0.0.0.0 保证 scanner/api 能互通,但宿主侧强制回环绑定——"带洞靶场暴露到局域网"是这类项目最常见的事故,这里用一行端口映射堵死。
其它收口:
api默认不设 API Key(注释明确"仅允许容器内/本地访问;生产部署请设置--api-key或环境变量")。grafana默认密码走GRAFANA_ADMIN_PASSWORD环境变量,GF_USERS_ALLOW_SIGN_UP=false。api带 healthcheck,打/api/system/health。- Prometheus 保留 7 天(
--storage.tsdb.retention.time=7d),配置挂载monitoring/prometheus.yml只读。
注:thinkphp / weaver 靶场已随插件迁移到外部 cms-scan-extras/,compose 里只剩两个 CMS 签名靶场。
5. CI 工作流#
.github/workflows/ 下 8 个文件:
| 工作流 | 触发 | 职责 |
|---|---|---|
| ci.yml | push/PR/tag | 主门禁(见下) |
codeql.yml | 定时/PR | CodeQL 静态分析 |
security-scan.yml | 定时/PR | 依赖与漏洞扫描 |
scorecard.yml | 定时 | OpenSSF Scorecard 供应链评分 |
release.yml | tag | 发布(等待 CI 门禁通过) |
desktop-release.yml | tag | 桌面端打包与发布 |
docs-site.yml | push | mkdocs 文档站构建 |
nightly-acceptance.yml | 每日 | 夜间验收 |
5.1 ci.yml 七个 job#
lint ──┬── unit(os × py 矩阵)
└── nuclei-templates
unit ──┬── signature-e2e(ruoyi + spring 靶场,要求全部 CONFIRMED)
├── report-format(PDF/Word/Excel + 去重)
├── waf-bypass(D7)
├── web-api(D9)
├── real-spring(期望 ≥11 CONFIRMED)
└── real-ruoyi(Java+MySQL,源码缺失时自动跳过,continue-on-error)
lint job 的三道硬门禁(都值得留意):
- ruff check 覆盖
tests/与scripts/——注释说明"测试代码同样是仓库主体(65 文件),此前不受约束导致未用导入与导入顺序持续漂移",而desktop/、lab/不纳入(独立产物 / 一次性脚本)。 - 版本固定:
pip install ruff==0.16.2 mypy==2.1.0。理由写在注释里:「ruff format 输出随版本演进,不固定会造成 CI 与本地格式漂移(v1.2.3/v1.2.4 期间 CI 连红的根因)」。 - mypy 门禁分层:
common/与core/全量--strict(G2 完成后从 350 个错误清零,去掉软门禁|| true),lib/只锁定已 strict 化的reporter.py、soft404.py。
两道"文档—代码一致性"门禁(较少见但很有价值):
python scripts/verification_matrix.py --check --max-none 13 # 无验证插件数只许降不许升
python scripts/verification_matrix.py > /tmp/matrix.md # 生成物必须与 docs/verification-matrix.md 一致
diff -u docs/verification-matrix.md /tmp/matrix.md
第一条用上限而非"必须为 0":注释解释"立即清零不现实,但数量只应下降——避免插件数涨、验证覆盖率降这种静默劣化"。
unit job 的覆盖率棘轮(按目录,而非全仓统一阈值):
| 目录 | 实测 | CI 阈值 |
|---|---|---|
core | 81% | 78 |
common | 87% | 84 |
lib | 75% | 72 |
api | 88% | 85 |
plugins | 78% | 75 |
chains | 100% | 95 |
放弃 --cov-fail-under 全仓门槛的理由写得很清楚:「统一阈值会让低覆盖模块躲在平均值后面(lib/ 5774 语句仅 75%,api/ 88%——均值把它们拉平)」。阈值取"当前值下浮 3 个点",只许升不许降。
e2e 的 --allow-safe 名单:signature-e2e 要求全部 CONFIRMED,但显式豁免 4 项,理由是它们结构性不可能 CONFIRMED:
Redis 未授权访问/MinIO 未授权访问/RocketMQ Dashboard 未授权——中间件探测,靶机无该服务即SAFE;shiro_rememberme——指纹类探测(category=recon,不挂 CVE),只有SAFE(无特征)或UNKNOWN(有特征需验密钥)两种判定。
Windows 矩阵的作用:os: [ubuntu-latest, windows-latest],注释写"国内使用主力环境之一,防编码(GBK)/路径分隔符回归"。pytest 超时方式也随平台切换:POSIX 用 signal,Windows 用 thread。
6. 测试体系#
6.1 规模与配置#
tests/ 约 66 个 .py。配置在 pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-q --timeout=30 --timeout-method=thread"
两个回归脚本不走 pytest:tests/regression_ruoyi.py、tests/regression_spring.py(CI 直接 python 执行)。
6.2 conftest.py 的三个关键设计#
tests/conftest.py 不只是堆 fixture,它解决了两个真实踩过的坑:
① 进程退出安全网(CI 挂起根因)
concurrent.futures 在 import 时通过 atexit.register(_python_exit) 注册了 handler,它会 join 所有 ThreadPoolExecutor 线程——包括 ScanEngine 内部线程池的非 daemon 线程。当 mock fixture 失效后后台线程加载真实插件并发起 HTTP 请求,join 无限等待 → pytest 进程挂起。
解法是抢在 _python_exit 之前注册自己的 handler,用 os._exit 绕过后续所有 atexit:
def _force_exit_bypass_thread_join():
_sys.stdout.flush(); _sys.stderr.flush()
_os._exit(_exit_code_holder["code"])
atexit.register(_force_exit_bypass_thread_join)
配套的 pytest_sessionfinish 记录 exitstatus,保证退出码正确。这与 03 章 里 _DaemonThreadPoolExecutor 的做法是同一个问题的两个战场。
② 会话级输出静默
_quiet_reporter fixture(autouse=True, scope="session")默认调 set_quiet(True)。原因:误报基线测试单次执行要跑 51 插件 × 多条语料,此前会倾倒约 2 MB 文本淹没失败信息。需要观察时设 RUOYI_SCAN_TEST_VERBOSE=1。
③ fixture 分层
| 层 | fixture | 用途 |
|---|---|---|
| 网络 mock | mock_router、mock_network | mock Router / detect_cms / detect_waf / load_plugins |
| 应用 | app、app_with_key、app_in_memory、client、client_with_key、client_factory | create_app() 带临时 SQLite |
| 核心对象 | storage、registry、registry_factory、mock_registry、orch | 直接实例化 |
| 请求工厂 | make_scan_request、sample_scan_request | 替代 22 处 ScanRequest 构造 |
| 目录 | tmp_report_dir、cache_db_path | 统一临时目录风格 |
两处刻意的约束:
clientfixture 显式依赖mock_network——保证orchestrator.shutdown()在 mock 仍生效时执行,避免后台线程脱离 mock 后加载真实插件发起 HTTP 导致挂起。mock_network里detect_cms返回真实FingerprintResult而非裸MagicMock——保证 fingerprint 事件 payload 可 JSON 序列化,维护 WS 推送 / 历史落盘的契约。conftest.py顶部说明:旧文件里的sys.path.insert与重复 fixture 不立即删除,新 fixture 供后续迁移,保证渐进式重构安全。
6.3 运行方式#
pip install -r requirements-dev.txt
pytest # 全量(addopts 已含 --timeout=30)
pytest tests/test_orchestrator.py -v # 单文件
pytest -q --timeout-method=signal # POSIX 上用 signal 超时
pytest --cov=core --cov=common --cov=lib --cov=api --cov=plugins --cov=chains
coverage report --include="*/core/*" --fail-under=78 # 复现 CI 棘轮
RUOYI_SCAN_TEST_VERBOSE=1 pytest # 需要看插件进度时
7. 辅助脚本 scripts/#
| 脚本 | 作用 |
|---|---|
| run_e2e.py | 端到端验收:起靶场 → 扫描 → 断言命中数(--require-all-confirmed / --min-confirmed N / --allow-safe) |
| verification_matrix.py | 生成检出能力矩阵("插件 × 有无自动化验证证据"),CI 用它做棘轮门禁 |
| run_fp_test.py | 误报测试驱动 |
| build_offline_cve.py | 构建离线 CVE 库 data/cve_offline.json |
| export_openapi.py | 导出 OpenAPI schema(供前端/文档使用) |
| build_release.sh | 发布构建 |
8. 小结:本章的设计意图#
| 现象 | 意图 |
|---|---|
| 引擎编译期嵌入而非运行时下载 | 断网可用、无中间人风险、单 exe |
| Job 句柄故意不关闭 | 壳被杀也要回收引擎树(注释显式声明"不是缺陷") |
靶场 safe 模式进 CI | 误报成为会红的 job,而非口头约定 |
靶场端口仅绑 127.0.0.1 | 带洞靶场绝不暴露局域网 |
| 覆盖率按目录设阈值 | 防止低覆盖模块躲在全仓均值后 |
--max-none 13 而非必须为 0 | 用棘轮替代不切实际的一次性清零 |
| ruff/mypy 版本固定 | 消除 CI 与本地格式漂移 |
conftest.py 抢注册 atexit | 绕过 _python_exit 的无限 join |
| 旧 fixture 不立即删除 | 渐进式重构,保证测试长期可运行 |
上一篇:06 Web API 与控制台 · 下一篇:08 依赖关系