1.77.6: SERP sorry-重定向按验证码语义(不换号+15min冷却);浏览池保温释放跳过+60min持有上限;速率-门壳实验与监控工具

This commit is contained in:
BigCode
2026-10-29 15:54:37 +08:00
commit 05d9462414
25 changed files with 3973 additions and 0 deletions
+483
View File
@@ -0,0 +1,483 @@
# ProfileMax:基于 CRIU 的 Chromium 浏览器快照实现方案
- 文档类型:实现方案 / PoC 计划
- 状态:已验证(2026-10-20 本机 PoC + node 端到端集成测试通过,实测数据见第 12 节)
- 目标路径:`/home/bigcode/data/dev/bun/SerpMax/docs/01_CRIU_BROWSER_SNAPSHOT.md`
## 1. 目标与范围
ProfileMax 需要保存已启动的 Chromium 浏览器运行现场。示例:浏览器已经打开 Google 并完成搜索,创建快照后结束原浏览器;后续恢复时尽可能直接回到原搜索结果页面,避免再次启动浏览器、打开网站、重新执行页面初始化,从而降低重复作业的启动延迟和 CPU 峰值。
本方案采用 Linux + Chromium + CRIU,先验证技术可行性,再开发快照管理层。
### 1.1 必须实现
- 对 Chromium 完整相关进程树执行 checkpoint / restore。
- 恢复页面当前的内存状态,重点验证 DOM、JavaScript 堆、滚动位置和页面交互。
- 快照成功后可以结束原进程;恢复时不依赖原 Chromium 进程仍然存在。
- 保存快照元数据,校验浏览器版本、启动参数和运行环境。
- 记录创建耗时、恢复耗时、快照大小、内存占用和 CPU 峰值。
- 恢复失败时能报告具体阶段和错误,后续可回退到普通浏览器启动流程。
### 1.2 明确不作为目标
- **不要求保持原 TCP、HTTP/2、WebSocket 等网络连接。** 恢复后允许浏览器和页面重新建立连接。
- 不保证 Google 或其他网站的服务端会话永远有效。
- 第一阶段不做跨主机迁移、不做快照分叉克隆、不开发通用虚拟机快照系统。
- 不自行实现进程内存快照引擎;使用 CRIU 完成底层检查点与恢复。
- 不修改 Chromium 源码,除非 PoC 证明现有接口无法满足必要条件。
> 网络连接不需要延续,不代表可以直接忽略进程持有的 socket。CRIU 在 dump 时仍必须能处理进程文件描述符及其内核资源。必须通过 PoC 验证连接相关资源的实际处理方式,不能假设不传输 TCP 就一定可以成功 dump。
## 2. 总体架构
```text
ProfileMax API / Job Manager
|
v
Browser Snapshot Manager
|
+-- Browser Runtime Manager
| +-- Chromium 启动与进程树识别
| +-- 固定 Profile / 启动参数 / 显示环境
|
+-- Checkpoint Adapter
| +-- CRIU pre-dump / dump / restore 能力探测
| +-- 命令执行、超时、日志和错误归一化
|
+-- Snapshot Store
| +-- CRIU image 目录
| +-- 元数据与环境指纹
| +-- 完整性检查
|
+-- Restore Validator
+-- 进程存活检查
+-- 浏览器 DevTools / 页面状态检查
+-- 页面交互与网络重连检查
```
建议管理层使用项目现有的 Bun/TypeScript 技术栈实现;CRIU 通过受控子进程调用。若当前项目已有统一任务执行或进程管理模块,应复用,不要另建重复框架。所有 CRIU 命令必须由服务端生成参数数组执行,禁止拼接用户输入到 shell 命令。
## 3. 关键技术约束
### 3.1 Chromium 是多进程程序
不能只对 Browser 主进程执行快照。需要识别并纳入同一检查点的相关进程,例如:
- Browser 主进程
- Renderer 进程
- GPU 进程
- Network Service / Utility 等子进程
- 与浏览器运行现场相关的其他子进程
PoC 需验证 CRIU 采用正确的进程树入口和权限设置,且恢复后进程关系及 IPC 可用。禁止仅凭一个 PID dump 就认为整个浏览器已被完整保存。
### 3.2 固定运行环境
第一阶段固定以下条件,减少变量:
- 同一台 Linux 主机、同一内核版本与 CRIU 版本。
- 同一 Chromium 二进制版本及启动参数。
- 同一用户、UID/GID、工作目录、环境变量和 Profile 路径。
- 相同的挂载点、字体、共享库、临时目录和必要设备。
- 固定显示模式;优先从无头 Chromium 或固定 Xvfb 环境中选定一种进行 PoC,不要在首轮同时测试多种模式。
- 固定代理配置。代理本身可以在恢复后重新连接,但其配置应保持一致。
快照元数据必须记录上述环境。若恢复环境与快照不兼容,应拒绝恢复并给出明确原因。
### 3.3 Chromium 沙箱与 GPU
Chromium sandbox、namespace、seccomp、GPU 设备、共享内存及文件描述符可能影响 CRIU 兼容性。
- 首轮先使用正常安全配置验证。
- 如果为了定位问题,需要临时关闭 GPU 或在隔离测试环境中调整沙箱,必须作为单独实验变量记录。
- 不得把 `--no-sandbox` 当作生产默认参数,也不得为了通过 PoC 而默默降低生产安全边界。
- 生产部署前必须明确支持矩阵,写明哪些 Chromium 参数和运行模式经过实测。
### 3.4 网络与 socket 处理
ProfileMax 不需要恢复原 TCP/WS 连接,但进程内仍可能保存 socket 文件描述符、epoll 状态及异步网络请求状态。
实施原则:
1. 不将网络连接连续性纳入快照成功标准。
2. PoC 分别测试 CRIU 默认行为,以及在 CRIU 支持和环境允许时对已建立 TCP 连接的处理行为。
3. 若 dump 因网络文件描述符失败,记录 CRIU 原始错误和对应 FD 类型,再选择可验证的处理方案;不要直接忽略错误。
4. 恢复后,允许应用层重建连接;对已经失效的请求和 WebSocket,页面自身必须能恢复或重新加载相关数据。
5. 如需在恢复后关闭陈旧连接,应采用可验证的浏览器/页面层策略;不要从外部任意关闭 Chromium 的 FD,这可能破坏进程内部状态。
### 3.5 页面状态边界
进程内存快照的目标是保留运行现场,但不能保证每个网站页面都能无缝续跑:
- 页面已渲染的 DOM 和 JavaScript 堆是重点验证项。
- 页面计时器、Promise、Service Worker、后台标签页、扩展进程和浏览器内部任务需要单独测试。
- 恢复后页面可能检测到网络断开、请求失败或登录状态失效。
- 页面现场恢复与网站业务状态恢复是两回事。服务端数据可能已经改变。
## 4. 快照目录与元数据
建议每个快照使用独立目录,CRIU image 文件和元数据分离管理:
```text
data/snapshots/
<snapshot-id>/
criu/
# CRIU 生成的 checkpoint image 文件
manifest.json
checkpoint.log
restore.log
```
`manifest.json` 建议字段:
```json
{
"schemaVersion": 1,
"snapshotId": "uuid",
"createdAt": "ISO-8601",
"status": "ready",
"browser": {
"name": "chromium",
"version": "exact-version",
"executable": "/usr/bin/chromium",
"rootPid": 1234,
"launchArgsHash": "sha256"
},
"runtime": {
"kernelRelease": "exact-release",
"criuVersion": "exact-version",
"uid": 1000,
"gid": 1000,
"profilePath": "/data/profiles/example",
"displayMode": "xvfb-or-headless",
"networkContinuityRequired": false
},
"metrics": {
"checkpointDurationMs": null,
"restoreDurationMs": null,
"snapshotBytes": null
},
"validation": {
"processTreeRestored": false,
"pageStateVerified": false,
"interactive": false
}
}
```
上例仅为结构示意。实际实现必须从运行环境采集真实值,不能硬编码 PID、版本或路径。敏感代理凭据、Cookie 和页面数据不得写入普通日志或 manifest。
## 5. 生命周期与状态机
建议快照状态:
- `creating`:正在执行 checkpoint。
- `ready`:checkpoint 完成,元数据和文件校验通过。
- `restoring`:正在恢复。
- `restored`:进程恢复成功且基础检查通过。
- `failed`:创建或恢复失败。
- `deleting`:正在清理。
创建流程(浏览器关闭前自动快照):
1. ProfileMax 统一管理浏览器关闭入口。任务完成、任务管理器要求关闭或用户主动关闭时,先进入 `closing` 流程;不要等 Chromium 已经开始退出后才监听 `close` 事件。
2. 检查目标浏览器实例和所有权,并确认任务已结束、没有正在执行的关键操作。
3. 记录 Chromium 版本、进程树、启动参数、运行环境及当前作业 URL。
4. 创建临时快照目录并执行 CRIU checkpoint/dump。
5. 收集退出码、stdout/stderr、CRIU 日志和耗时;校验检查点文件完整并写入 manifest。
6. 校验通过后,原子地将快照标记为 `ready`,并将其关联到对应浏览器实例/任务。
7. 只有快照已可靠落盘并标记为 `ready` 后,才完成浏览器关闭流程。注意:常规 CRIU dump 通常会让被 dump 的进程停止/退出,因此应由 CRIU 的实际 dump 语义完成进程收尾,不能再无条件调用 Chromium 的正常关闭。
8. 如果快照失败,记录错误并按配置执行普通关闭;不要把不完整快照标记为 `ready`。若产品要求必须先保留一个仍运行的 Chromium 再正常关闭,需要单独验证 leave-running 模式与 Profile 文件一致性,不能直接把它当作安全默认方案。
关闭事件的实现要求:使用 ProfileMax 自己的统一关闭方法/生命周期钩子作为触发点;浏览器进程已经退出后的 `close`/`exit` 通知只用于清理和记录结果,不用于启动快照。
恢复流程:
1. 校验 manifest、快照文件、版本和运行环境。
2. 确保没有冲突的同一实例正在运行;首版不支持从同一快照并发恢复多个实例。
3. 准备恢复所需的用户、目录、挂载、网络命名空间和显示环境。
4. 执行 CRIU restore,保存日志和耗时。
5. 验证恢复后的进程树、浏览器调试接口(如果启用)、原页面状态及用户交互。
6. 对网络重连进行观察,但不要求旧连接继续存在。
7. 只有验证通过才标记为 `restored`;失败时保留诊断材料,避免自动覆盖原快照。
注意:CRIU 的具体命令行参数、权限要求和网络选项必须以部署机器上安装版本的 `criu --help`、官方文档和 PoC 结果为准。本文不预设某一组命令在所有内核/CRIU 版本上通用。
## 6. 模块接口建议
管理层可先实现以下内部接口,具体参数根据现有 ProfileMax 代码规范调整:
```ts
type SnapshotStatus =
| "creating"
| "ready"
| "restoring"
| "restored"
| "failed"
| "deleting";
interface SnapshotManifest {
schemaVersion: number;
snapshotId: string;
createdAt: string;
status: SnapshotStatus;
browser: {
name: "chromium";
version: string;
executable: string;
rootPid: number;
launchArgsHash: string;
};
runtime: {
kernelRelease: string;
criuVersion: string;
uid: number;
gid: number;
profilePath: string;
displayMode: string;
networkContinuityRequired: false;
};
}
interface CheckpointResult {
snapshotId: string;
durationMs: number;
snapshotBytes: number;
logPath: string;
}
interface RestoreResult {
snapshotId: string;
durationMs: number;
restoredPids: number[];
validationPassed: boolean;
}
interface CheckpointAdapter {
checkCapabilities(): Promise<{
available: boolean;
criuVersion?: string;
reasons: string[];
}>;
checkpoint(browserRootPid: number, snapshotDir: string): Promise<CheckpointResult>;
restore(snapshotDir: string): Promise<RestoreResult>;
}
```
这些接口是设计草案,不代表项目中已经存在对应模块。需要先查看 ProfileMax 当前目录结构和现有进程管理实现,再决定最终落点。
## 7. PoC 实施计划
### P0:环境检查
- 确认 Linux 发行版、内核版本、CRIU 版本及所需权限。
- 运行 CRIU 自检,并保存完整输出。
- 检查 Chromium 的实际进程树、命名空间、打开的 FD、共享内存和设备依赖。
- 记录当前 Chromium 版本、启动参数和运行模式。
交付物:环境报告、CRIU capability 报告、浏览器进程树样本。
### P1:最小 checkpoint / restore
- 启动 Chromium 并打开固定测试页面。
- 在页面内创建可验证的 JavaScript 状态标记,例如计数器、随机 token、滚动位置和 DOM 文本。
- 执行 checkpoint,确认原实例已按预期停止或退出。
- restore 后读取页面标记,验证其与 checkpoint 前一致。
- 检查页面是否可点击、输入和滚动。
交付物:可重复执行的测试脚本、CRIU 日志、成功/失败原因。
### P2:真实搜索页面
- 打开 Google 并执行固定关键词搜索,按正常流程完成测试任务。
- 任务完成、浏览器准备关闭时,由 ProfileMax 关闭流程触发快照;不使用定时快照,也不需要单独监听页面稳定事件。
- 快照完成并可靠落盘后,按 CRIU dump 的实际语义结束原进程,再执行恢复测试。
- 验证 URL、DOM、滚动位置和页面交互。
- 观察网络重连后的页面行为;不把旧 TCP/WS 保持连接作为成功条件。
交付物:真实网站验证记录。若网站行为不稳定,使用本地测试页面作为可重复基线,并将网站测试作为补充。
### P3:基准测试
至少重复 20 次,分别测量普通冷启动和快照恢复:
| 指标 | 冷启动 | CRIU 恢复 |
|---|---:|---:|
| 总耗时 P50 / P95 | 测量 | 测量 |
| CPU 峰值 | 测量 | 测量 |
| 恢复后可交互耗时 | 测量 | 测量 |
| 快照体积 | 不适用 | 测量 |
| 失败率 | 测量 | 测量 |
必须统一测试条件:相同主机、Chromium 版本、Profile、页面、代理和资源状态。单次结果不能作为结论。
### P4:管理器封装
只有 P1/P2 证明可行后再实施:
- snapshot create / restore / inspect / delete。
- 状态机和并发锁。
- 日志、错误分类、超时和取消。
- 快照容量限制及过期清理。
- 环境兼容性校验和失败降级。
- 权限隔离,避免普通用户对任意系统进程执行 dump/restore。
## 8. 验收标准
首阶段必须全部满足:
1. 能对目标 Chromium 进程树成功 checkpoint。
2. 原浏览器进程停止后,能够从快照恢复。
3. 恢复后原页面的验证标记、URL、主要 DOM 状态及滚动位置符合预期。
4. 页面可继续交互,且允许网络连接重新建立。
5. 失败时能够通过日志定位到具体 CRIU 错误或资源依赖。
6. 测得冷启动与恢复的耗时、CPU 峰值、快照大小和失败率。
性能目标在取得基线数据后设定,不预先承诺快照恢复一定比冷启动快。快照读取、内存恢复和页面恢复验证都可能消耗显著的磁盘 IO 与 CPU。
## 9. 安全与运维要求
- 快照可能包含 Cookie、认证令牌、页面内容及其他敏感内存数据,按敏感数据保护。
- 快照目录仅允许浏览器运行用户和快照管理服务访问,权限默认最小化。
- 不在日志中输出 Cookie、Authorization、代理密码或页面表单内容。
- 只允许管理器操作其登记的 Chromium 进程,不接受任意 PID 的外部请求。
- CRIU dump/restore 使用最小必要权限;如需 root 权限,限定服务边界并记录审计日志。
- 删除快照时清理完整目录,并记录清理结果。
- 版本、内核或启动参数不兼容时拒绝盲目恢复。
## 10. 参考资料
- CRIU:<https://github.com/checkpoint-restore/criu>
- CRIU 官方文档:<https://criu.org/>
- CRIU 使用手册:<https://criu.org/Basic_usage>
- Podman checkpoint/restore(用于参考容器集成方式):<https://docs.podman.io/>
## 11. 当前结论
采用“CRIU 底层 + ProfileMax 自有管理层”的路线。第一阶段只交付 PoC,不提前承诺 Chromium 全进程树一定可恢复。优先验证真实运行环境中的沙箱、GPU、共享资源和 socket 处理。TCP/WS 连续性不是验收目标;进程仍需正确处理网络相关 FD,恢复后允许应用重新连接。PoC 通过后再扩展为多实例快照管理。
## 12. 实测结论与落地记录(2026-10-20)
### 12.1 环境与实测数据
- 环境:Zorin OS 18.1(Ubuntu noble 基),内核 7.0.0-31-generic,veil-chromium 153.0.8010.52;
CRIU 4.2(源码编译,`/usr/local/bin/criu`,文件能力 `cap_sys_ptrace,cap_sys_admin,cap_net_admin+eip`,
非 root 可用,免改 ptrace_scope)。
- PoC(直启 chrome → baidu 搜索页 → dump → restore):
| 指标 | 实测值 |
|---|---|
| 冷启动(进程拉起 → CDP 就绪) | ≈ 0.6s |
| dump(9 进程树,RSS ≈ 1.13GB) | ≈ 1.3s,镜像 ≈ 354MB(397 个 .img) |
| restore(detached) | ≈ 0.36s |
| 现场校验 | URL/标题/JS 堆标记/滚动位置/搜索结果 DOM/localStorage 全部一致,`navigator.onLine=true` |
| 可交互 | 恢复后二次搜索成功(网络自动重连) |
- node 端到端(经 ProfileMax API:创建快照 Profile → 启动 → 搜索 → 停止自动 dump → 再启动):
停止含 dump ≈ 1.3s;再次启动 ≈ 0.4s 且 `restored:true`,CDP 端口原样恢复,现场校验同上全绿
(复测脚本 `node/scripts/criu-poc.ts`、`node/scripts/criu-e2e.ts`)。
### 12.2 关键结论(实测定论)
1. **沙箱与 CRIU 的结构性冲突**:criu 拒绝对「非根进程自建的嵌套 PID namespace」执行 dump
(`CLONE_SUBNS` 仅含 mount/net),而 chromium namespace 沙箱的 zygote/渲染进程恰在嵌套 pidns 中。
→ **快照模式 Profile 启动时显式追加 `--no-sandbox`**(browser.manager `snapshotSandboxArgs`);
生产节点为 root 容器,ProfileMax 本就强制 `--no-sandbox`,此处仅影响非 root 环境,非全局降级。
2. **仅支持 headless**:有头模式连接的 X11/Wayland socket 是树外 STREAM unix socket,
criu 的 `--ext-unix-sk` 仅支持 DGRAM 外部连接 → 有头实例停止时不做快照(回退常规关闭)。
3. **D-Bus 必须「干净失败」隔离**:chrome 对已建立后断开的 dbus 连接会 FATAL
("D-Bus connection was disconnected. Aborting.")。快照模式启动 env 将
`DBUS_SESSION_BUS_ADDRESS`/`DBUS_SYSTEM_BUS_ADDRESS` 指向 `unix:path=/nonexistent-bus`
(与生产容器无 bus 的现实一致);地址格式非法(如 `/dev/null`)会导致导航失败,不可用。
4. **crashpad handler 在树外**:chrome 会把 handler daemonize 到进程树外(subreaper 接管),
其 SEQPACKET socketpair 端为树外对端。已给 criu 打补丁:dump 允许外部 STREAM/SEQPACKET,
restore 侧对非 DGRAM 外部对端跳过 connect(以「未连接」语义恢复,客户端优雅降级);
补丁内容两处(criu/fsnotify.c 悬空 inotify watch 跳过 + criu/sk-unix.c 外部 socket 放行),
生产镜像需携带补丁版 criu。
5. **chromium 共享内存临时文件**(`.org.chromium.Chromium.*`,实测 4MB 起步)远超 criu 默认
1MB ghost 限额 → dump/restore 统一 `--ghost-limit=512MB`(env `CRIU_GHOST_LIMIT_MB` 可调)。
6. **dump 参数**:`--file-locks --tcp-close --ext-unix-sk --ghost-limit=N [--unprivileged]`;
`--tcp-close` 表示已建 TCP 连接按「关闭」落镜像(符合 1.2 节「不要求连接连续性」),
恢复后由页面/浏览器自行重连,实测 baidu 页面正常。
7. **启动方式约束**:快照 Profile 由本服务直启(setsid 保证 session leader + 显式
`--user-data-dir`),不经 playwright 管道(管道对端在树外无法 dump);CDP 经 connectOverCDP
挂接,停止时 playwright 句柄 close() 仅断连不杀进程。
8. **参数哈希与端口绑定**:dump 时将完整启动参数(含 `--user-data-dir` 打头)哈希入 manifest,
restore 前重算比对,任一不一致拒绝恢复回退冷启动;impersonate MITM 端口记入 manifest,
恢复时 `ensureImpersonate` 优先复用同端口(浏览器重连无缝),端口被占则拒绝恢复。
9. **陈旧 DevToolsActivePort**:dump 后该文件残留,直启前必须删除,否则 CDP 就绪探测
可能打在死端口上(实测踩坑)。
10. **criu 单次操作超时 180s**,dump/restore 失败不重试:回退常规关闭/冷启动,
失败快照保留 `failed` 状态与日志供诊断(manifest.json / dump.log / restore.log)。
### 12.3 落地范围(本版)
- Profile 新增 `snapshot.enabled` 配置(admin「快照模式」开关,缺省关闭;仅 veil + headless 生效);
- 停止浏览器时自动 checkpoint(存储快照抓取先于 dump),启动时优先 restore 并回写 CDP/条目;
- 节点 API:`GET/DELETE /profiles/:id/snapshot`;admin 中继同路径 GET/DELETE + PUT 快捷开关;
- admin 概览卡:快照(含大小)/ 自动CF挑战 / 自动挑战 reCAPTCHA 三开关同行;
- 快照目录:`<profileDir>/<profileId>/snapshot/`(manifest.json + criu/ 镜像 + dump/restore 日志),
随 Profile 删除一并清理;同一 Profile 仅保留最近一次快照。
## 13. 镜像压缩与 USER_DATA 清理(2026-10-21,node v0.38.0 / admin v1.55.0)
### 13.1 镜像压缩(zstd 后处理)
CRIU 4.x 核心已移除内建压缩选项(旧版 `-z/--compress` 不存在,`criu-image-streamer` 需额外组件),
采用「dump 后 zstd 压缩大镜像 / restore 前解压还原」的后处理方案(snapshot.manager):
- dump 成功后(进程已退出)对镜像目录中 >1MB 的 `*.img` 逐文件 `zstd -3 -T0` 压缩,
压缩成功才删原文件,单文件失败保留原样不阻塞;`SNAPSHOT_COMPRESS=off` 可整体关闭。
- restore 前把 `*.img.zst` 解压还原(criu 不识别压缩镜像),耗时记入 manifest
`metrics.decompressDurationMs`;压缩前/后体积分别记 `snapshotBytesRaw`/`snapshotBytes`。
- 实测(同环境同场景,baidu 搜索现场,两轮):
| 指标 | 压缩前(10-20 基线) | 压缩后(10-21 实测) |
|---|---|---|
| 快照镜像大小 | 308~312MB | **67.5~70.6MB(约 4.4x 缩小)** |
| dump 耗时 | ≈1.0~1.3s | ≈1.0~1.3s(不变) |
| 停止总耗时(含 dump+压缩) | ≈1.2~1.3s | ≈2.4s(压缩约 +1.1s,zstd -3 -T0) |
| restore(criu 自身) | ≈0.3s | ≈0.3s(不变) |
| restore 前解压 | — | ≈0.58s |
| 再次启动总耗时 | ≈0.43~0.58s | ≈1.11~1.16s(解压 +0.6s) |
| 现场校验(8 项) | 全绿 | 全绿(URL/标题/JS 标记/滚动/localStorage/结果数/在线/二次搜索) |
结论:以「每次停止 +≈1.1s、每次恢复 +≈0.6s」换「磁盘占用 -77%」,默认开启;
磁盘敏感场景收益显著,对恢复可用性无影响。
### 13.2 USER_DATA 静态缓存清理
- **自动清理(默认开启)**:浏览器停止时实测 USER_DATA 体积(关闭前采样)超过
`USERDATA_CLEAN_THRESHOLD_MB`(默认 100MB)→ 进程完全退出后软清理静态缓存
(cache.manager `autoCleanOnStop`);清理后体积才回传 admin(profiles.user_data_size)。
- **软清理范围(CACHE_DIRS)**:根级 + `Default/` 子目录双布局——HTTP Cache、Code Cache、
GPUCache、Dawn/GrShader/Graphite 缓存、Media Cache、Shared Dictionary、组件 crx 缓存等;
Cookie/LocalStorage/IndexedDB/Service Worker/登录态一律保留(实测主要占用
`Default/Cache` + `Default/Code Cache`,约占 USER_DATA 80%)。
- **手动清理**:节点 `POST /profiles/:id/cleanup`(admin 中继 `POST /api/profiles/:id/cleanup`,
Profile 详情页「编辑」与「复制」之间的「清理」按钮):软清理 + **删除现有 CRIU 快照**
(快照镜像含缓存页,restore 会原样还原缓存,不删等于没清;下次停止自动重建快照);
浏览器运行中节点拒绝(`CACHE_CLEAR_FAILED`,提示先停止)。返回 before/after/freedBytes
与 snapshotDeleted,前端 toast 展示释放体积。
- 验证:130MB 假缓存(Default/Cache)触发清理释放 120MB,Cookie/LocalStorage 保留;
50MB 未触发;E2E 全流程(含手动清理后快照 bytes=0)通过。
### 13.3 启动方式对比:正常冷启动 vs 快照恢复(到达同一现场)
口径:从 `POST /browser/start` 发起,到「baidu 同一关键词搜索结果页 DOM 已渲染」为止
(CDP 连接与现场确认均计入);基准脚本 `node/scripts/criu-bench-compare.ts`,两轮实测:
| 阶段 | 冷启动(非快照) | 快照恢复 |
|---|---|---|
| 启动 → CDP 就绪 | 0.80~0.96s | 0.99~1.51s(含解压≈0.6s + restore≈0.3s) |
| 导航加载搜索结果 | 2.52~4.08s(随网络波动) | 0(现场已在,免导航) |
| 现场确认 | 0.08~0.11s | 0.04~0.32s |
| **到达同一现场总计** | **3.69s / 4.44s** | **1.18 / 1.40 / 1.49 / 1.59s(均值 ≈1.4s)** |
| 现场一致性 | — | 4/4 轮 URL/标题/结果数完全一致 |
| **加速比** | — | **2.86x / 2.88x(两轮稳定)** |
附:快照 Profile 自身冷启动(3.61s/5.14s)与普通 Profile 无差异,快照模式不拖累冷启动;
本组 dump 耗时 1.7~3.3s、快照 60.4~70.9MB(压缩后),dump 耗时随当时浏览器内存占用浮动。
+929
View File
@@ -0,0 +1,929 @@
# SerpMax API 文档
> 版本:2026-09-24(依据源码 `admin/src/server.ts` 与 `node/src/api/` 整理)
> 架构:**admin**(后台,默认端口 `5310`)与 **node**(Profile Manager 节点,默认端口 `3100`,容器宿主映射常见为 `3106`)。
> admin 的浏览器操作类接口大多是**中继**:admin 收到请求后按 `profile.node → nodes.base_url` 转发到对应节点,并自动附带 admin API Token 与节点密钥。
---
## 目录
- [1. 通用约定](#1-通用约定)
- [1.1 统一响应信封](#11-统一响应信封)
- [1.2 认证](#12-认证)
- [1.3 公共请求头](#13-公共请求头)
- [1.4 错误码表](#14-错误码表)
- [2. Admin API(端口 5310)](#2-admin-api-端口-5310)
- [2.1 认证 /api/auth/*](#21-认证-apiauth)
- [2.2 仪表盘 /api/dashboard](#22-仪表盘-apidashboard)
- [2.3 Profile 管理 /api/profiles](#23-profile-管理-apiprofiles)
- [2.4 浏览器操作中继(浏览器/VNC/打码)](#24-浏览器操作中继)
- [2.5 Cookie 管理(admin 本地 Jar)](#25-cookie-管理admin-本地-jar)
- [2.6 代理管理 /api/proxies](#26-代理管理-apiproxies)
- [2.7 节点管理 /api/nodes](#27-节点管理-apinodes)
- [2.8 节点代码包与自更新](#28-节点代码包与自更新)
- [2.9 日志 /api/logs](#29-日志-apilogs)
- [2.10 系统设置 /api/settings](#210-系统设置-apisettings)
- [2.11 事件上报 /api/events](#211-事件上报-apievents)
- [3. Node API(端口 3100)](#3-node-api-端口-3100)
- [3.1 Profile](#31-profile)
- [3.2 Lease(租约)](#32-lease租约)
- [3.3 浏览器生命周期](#33-浏览器生命周期)
- [3.4 浏览器操作(Action + SSE)](#34-浏览器操作action--sse)
- [3.5 HTTP 请求执行(fetch / browse / browser-fetch)](#35-http-请求执行)
- [3.6 Cookie(node 运行时)](#36-cookienode-运行时)
- [3.7 缓存管理](#37-缓存管理)
- [3.8 Schema 与 Execution](#38-schema-与-execution)
- [3.9 reCAPTCHA 打码](#39-recaptcha-打码)
- [3.10 VNC 与直连](#310-vnc-与直连)
- [3.11 系统 /health /ready /metrics 等](#311-系统)
- [4. 附录](#4-附录)
- [4.1 Browser Action 类型一览](#41-browser-action-类型一览)
- [4.2 SSE 事件格式](#42-sse-事件格式)
- [4.3 典型调用流程](#43-典型调用流程)
---
# 1. 通用约定
## 1.1 统一响应信封
所有 JSON 接口(除 `/metrics`、图片、SSE、脚本下载外)使用统一信封:
成功:
```json
{ "success": true, "data": { ... } }
```
失败:
```json
{
"success": false,
"error": {
"code": "PROFILE_NOT_FOUND",
"message": "Profile 不存在",
"requestId": "req_xxxxxxxxxxxx",
"details": {}
}
}
```
- 每个响应都带 `x-request-id` 响应头;请求可自带 `x-request-id` 头,否则自动生成。
- admin 的部分错误信封为 `{ success:false, error:{ code, message } }`(无 requestId 字段),语义一致。
## 1.2 认证
### Admin API
| 方式 | 说明 |
|---|---|
| 会话 Cookie | `POST /api/auth/login` 后下发 `smax_session`(HttpOnly,默认 7 天) |
| API Token | `Authorization: Bearer smax_xxx...`(在 设置页 或 `GET /api/settings` 获取,可轮换) |
免会话的例外端点:
| 端点 | 凭证 |
|---|---|
| `POST /api/nodes/register` | 无需鉴权(`key` 即身份,须与 provision 下发的密钥一致) |
| `POST /api/nodes/heartbeat` | 会话/API Token,**或**该节点自身的通信密钥(`x-node-key` / `Bearer <NODE_KEY>`) |
| `GET /node/install`、`GET /node/package` | query 中的节点密钥 `?key=` |
| `GET /node/version` | 公开 |
### Node API
- 配置了环境变量 `NODE_KEY` 后,所有路由要求携带:
- `x-node-key: <NODE_KEY>`,或
- `Authorization: Bearer <NODE_KEY>`
- 未配置 `NODE_KEY` 时全部放行(本机/内网模式)。
- 例外(在节点密钥之外**额外**支持一次性直连票据,供前端跨源直连):
- `POST /profiles/:id/browser/actions`、`GET /profiles/:id/browser/events`(SSE)、`GET /profiles/:id/vnc/ws`(WebSocket)
- 票据来源:`POST /profiles/:id/direct-ticket`(仅持节点密钥者可调)→ `{ ticket, expiresInMs }`,绑定 Profile、5 分钟有效;actions 用后即焚,events/VNC 在 TTL 内可复用。携带方式:`?ticket=` 或 `x-direct-ticket` 头。
## 1.3 公共请求头
| 头 | 说明 |
|---|---|
| `x-request-id` | 可选。请求追踪 ID,缺省自动生成,原样回传于响应头与错误信封 |
| `idempotency-key` | 可选。`POST /profiles`(node)创建 Profile 幂等键 |
| `x-lease-id` | 可选。node 浏览器操作 / schema run 时声明 Lease 持有者(也可在 body 传 `leaseId`) |
跨源直连:node 对所有接口返回 CORS 头(`access-control-allow-origin: *`),`OPTIONS` 预检统一应答 204。
## 1.4 错误码表
node 侧规范错误码(`node/src/types/common.ts`,括号内为默认 HTTP 状态):
| 类别 | 错误码 |
|---|---|
| Profile | `PROFILE_NOT_FOUND`(404)、`PROFILE_BUSY`(409)、`PROFILE_INVALID`(400)、`PROFILE_CREATING`(409)、`PROFILE_DELETING`(409)、`PROFILE_DISK_LIMIT`(413) |
| Lease | `LEASE_NOT_FOUND`(404)、`LEASE_EXPIRED`(410)、`LEASE_CONFLICT`(409) |
| Browser | `BROWSER_NOT_RUNNING`(409)、`BROWSER_START_FAILED`(500)、`BROWSER_CRASHED`(500)、`BROWSER_TIMEOUT`(504)、`PROFILE_NOT_RUNNING`(409)、`BROWSER_SESSION_NOT_FOUND`(409) |
| Action | `INVALID_ACTION`(400)、`INVALID_ACTION_PARAMETER`(400)、`INVALID_SELECTOR`(400)、`ELEMENT_NOT_FOUND`(404)、`ELEMENT_NOT_VISIBLE`(400)、`ELEMENT_NOT_INTERACTABLE`(400)、`NAVIGATION_FAILED`(502)、`ACTION_TIMEOUT`(504)、`SCREENSHOT_FAILED`(500) |
| Schema | `SCHEMA_NOT_FOUND`(404)、`SCHEMA_INVALID`(400)、`SCHEMA_VERSION_NOT_FOUND`(404)、`SCHEMA_EXECUTION_FAILED`(500)、`SCHEMA_TIMEOUT`(504) |
| Fetch | `FETCH_FAILED`(502)、`FETCH_TIMEOUT`(504)、`PROXY_FAILED`(502) |
| Cache | `CACHE_CLEAR_FAILED`(500) |
| 通用 | `VALIDATION_FAILED`(400)、`UNAUTHORIZED`(401)、`NOT_FOUND`(404)、`CONFLICT`(409)、`INTERNAL_ERROR`(500) |
admin 侧常用错误码:`UNAUTHORIZED`(401)、`INVALID_CREDENTIALS`(401)、`VALIDATION_FAILED`(400)、`PROFILE_NOT_FOUND`(404)、`FINGERPRINT_CONFLICT`(409)、`PROXY_NOT_FOUND`(404)、`COOKIE_NOT_FOUND`(404)、`NODE_NOT_FOUND`(404)、`NODE_NOT_AVAILABLE`(503)、`NODE_RELAY_FAILED`(502)、`DIRECT_UNAVAILABLE`(502/503)、`PACKAGE_FAILED`(500)。
---
# 2. Admin API(端口 5310)
## 2.1 认证 `/api/auth`
### POST /api/auth/login — 登录
```json
// 请求
{ "username": "admin", "password": "******" } // username 缺省为 admin
// 200 → data
{ "username": "admin" }
// 失败 401 INVALID_CREDENTIALS
```
成功时 `Set-Cookie: smax_session=...; HttpOnly; SameSite=Lax`。
### POST /api/auth/logout — 登出 → `data: null`,清除 Cookie
### GET /api/auth/me — 当前登录状态
```json
{ "authenticated": true, "username": "admin" }
// 未登录也返回 200:{ "authenticated": false, "username": null }
```
## 2.2 仪表盘 `/api/dashboard`
### GET /api/dashboard — 概览数据一次返回
```json
// data
{
"statistics": {
"totalProfiles": 10, "runningProfiles": 3, "errorProfiles": 0,
"totalProxies": 8, "captcha": 12, "recaptcha": 5, "requests": 1200
},
"activity": [ { "id": "12345678", "name": "...", "status": "running",
"browserType": "browser", "proxyName": "...", "totalRequests": 100,
"lastUsedAt": "...", "updatedAt": "..." } ],
"captchaSeries": [ /* 近 7 天验证码统计 */ ],
"requestSeries": [ /* 近 7 天请求统计 */ ],
"recentLogs": [ /* 最近 12 条日志 */ ]
}
```
## 2.3 Profile 管理 `/api/profiles`
> `:id` 统一接受 **Profile UUID 或指纹数字**(6~8 位);节点侧运行 ID 一律为指纹数字。
### GET /api/profiles — 列表(联表统计)
Query:`q`(搜索)、`status`、`proxyId`、`node`、`sort`、`order`、`page`、`limit`
```json
// data
{
"total": 10, "page": 1, "limit": 20,
"items": [ {
"id": "12345678", "name": "...", "fingerprint": "12345678",
"node": "node-1", "status": "running", "browserType": "browser",
"browser": { /* 指纹快照 */ }, "tlsPreset": "...",
"proxyId": "uuid", "proxyName": "...", "proxyStatus": "connected", "proxyType": "socks5",
"statistics": {
"totalRequests": 0, "successfulRequests": 0, "failedRequests": 0,
"captchaCount": 0, "recaptchaCount": 0, "recaptchaSolved": 0, "lastCaptchaAt": null
},
"lastUsedAt": null, "createdAt": "2026-09-01T00:00:00.000Z"
} ]
}
```
### POST /api/profiles — 创建 Profile
```json
// 请求(全部可选)
{
"name": "my-profile",
"fingerprint": "12345678", // 6~8 位数字(100000~99999999),缺省自动生成;冲突 → 409 FINGERPRINT_CONFLICT
"browserType": "browser", // 仅支持 "browser"
"proxyId": "uuid", // 绑定代理
"node": "node-1", // 指派运行节点(随分配自动注册节点)
"notes": "备注"
}
// 201 → data:Profile 详情(同 GET /api/profiles/:id 的 profile 部分)
```
### GET /api/profiles/nodes — 已有 Profile 的运行节点列表(去重,用于筛选)→ `data: ["node-1", ...]`
### GET /api/profiles/:id — 详情
```json
// data
{
/* Profile 详情(指纹、代理、网络、统计等) */
"cookieCount": 12,
"captchaEvents": [ { "id": "1", "type": "recaptcha", "website": "...", "url": "...", "createdAt": "...", "metadata": {} } ],
"logs": [ { "id": "1", "time": "...", "level": "info", "type": "...", "message": "..." } ]
}
```
### PUT /api/profiles/:id — 编辑基础信息
请求体(字段均可选):`{ name?, browserType?, proxyId?(null 解绑), node?(空串回退 "local"), notes? }` → `data: Profile 详情`。
变更节点/代理会自动写系统日志,节点随分配自动注册。
### DELETE /api/profiles/:id — 删除 → `data: null`
### POST /api/profiles/:id/duplicate — 复制(克隆指纹)
请求体 `{ name? }`(缺省 `<原名>-copy`)→ `201`,data 为新 Profile 详情。
### POST /api/profiles/:id/start · /stop — 启动/停止(状态标记 running/stopped)
→ `data: Profile 详情`。(真实浏览器启停走 [2.4 浏览器操作中继](#24-浏览器操作中继)。)
### POST /api/profiles/:id/fetch — 通过 Profile 运行时执行请求(转发到节点)
```json
// 请求
{
"runtime": "fetch", // fetch | browser | page;缺省取 Profile 的 browserType
"url": "https://example.com",
"method": "GET", // GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS
"headers": {}, // 透传
"body": "...", // 透传(字符串)
"timeoutMs": 30000 // 1000~120000
}
```
转发规则:`runtime=fetch` → 节点 `POST /profiles/:id/fetch`(指纹 UA/头序 + Cookie Jar + impersonate 代理链);`browser` / `page` → 节点 `POST /profiles/:id/browser/fetch`(真实浏览器渲染 / 页面上下文 fetch)。
```json
// data(RunResult,归一化后)
{
"ok": true, "status": 200, "headers": {}, "bodyText": "<html>...",
"truncated": false,
"timingMs": 812, "runtime": "browser",
"captcha": { "type": "recaptcha", "kind": "cloudflare", "website": "...", "url": "..." } // 检出时才有
}
```
`bodyText` 完整返回;仅当响应体超过节点 `FETCH_BODY_LIMIT`(默认 5MB,环境变量可调)时被截断,此时 `truncated: true`(非文本 content-type 时 bodyText 为 base64,`bodyBase64` 见节点侧对应接口)。响应命中 CF 挑战时节点自动回退浏览器过盾(见 3.5),此时 `runtime` 可能为 `browser-fallback` 且 `browserSolved: true`。
结果自动落库:请求统计、验证码事件。
## 2.4 浏览器操作中继
以下端点均需 admin 会话/API Token;admin 负责:解析 Profile → 定位节点(`nodes.base_url`)→ 自动**幂等导入** Profile 到节点(按指纹,节点侧缺失时以 admin 声明的指纹/代理创建)→ 附 `x-node-key` 转发。请求/响应与节点侧同名接口一致,细节见第 3 章对应小节。
| Admin 端点 | 方法 | 转发到节点 | 说明 |
|---|---|---|---|
| `/api/profiles/:id/browser-status` | GET | `GET /browser/status` | 浏览器/VNC 状态 |
| `/api/profiles/:id/browser-actions` | POST | `POST /browser/actions` | 执行动作批次(4MB 上限) |
| `/api/profiles/:id/browser-events?quality=` | GET | `GET /browser/events` | SSE 中继(state/screenshot/action/error) |
| `/api/profiles/:id/browser-restart` | POST | `POST /browser/restart` | 重启浏览器 |
| `/api/profiles/:id/browser-stop` | POST | `POST /browser/stop` | 关闭浏览器 |
| `/api/profiles/:id/browser-window` | POST | `POST /browser/window` | 新建窗口 |
| `/api/profiles/:id/cf-solver` | POST | `POST /cf-solver` | 自动 CF 挑战开关,`{ enabled: bool }` |
| `/api/profiles/:id/vnc/restart` | POST | `POST /vnc/restart` | 重启/恢复 x11vnc |
| `/api/profiles/:id/vnc/paste` | POST | `POST /vnc/paste` | VNC 粘贴,`{ text }`(1MB 上限) |
| `/api/profiles/:id/vnc/ws` | GET(WS) | `WS /vnc/ws` | noVNC WebSocket 中继(鉴权后 upgrade) |
| `/api/profiles/:id/browser-cookies` | GET / PUT | `GET·PUT /cookies` | 读取/覆盖导入节点浏览器 Cookie(8MB 上限) |
| `/api/profiles/:id/cookie-filter` | GET / PUT | `GET·PUT /cookie-filter` | Cookie 同步黑白名单 |
| `/api/profiles/:id/captcha/state` | GET | `GET /captcha/state` | 打码状态 |
| `/api/profiles/:id/captcha/events` | GET | `GET /captcha/events` | 打码状态 SSE |
| `/api/profiles/:id/captcha/image` | GET | `GET /captcha/image` | 主图(jpeg 原样透传) |
| `/api/profiles/:id/captcha/tile/:idx` | GET | `GET /captcha/tile/:idx` | 格子图 |
| `/api/profiles/:id/captcha/:action` | GET / POST | `GET·POST /captcha/:action` | `action ∈ start·adopt·click·verify·reload·action·autosolve·status·detach`,未知 action → 404 |
| `/api/profiles/:id/direct-session` | GET | `POST /direct-ticket` | 签发直连会话,见下 |
### GET /api/profiles/:id/direct-session — 直连会话
前端 VNC/浏览器操作直连节点的前置:admin 以节点密钥向节点索取一次性票据,密钥与 API Token 均不出服务端。
```json
// data
{ "nodeUrl": "https://node.example.com", "profileId": "12345678", "ticket": "dtk_...", "expiresInMs": 300000 }
```
- 节点未配置公开地址(`nodes.public_url`)→ `503 DIRECT_UNAVAILABLE`,前端回退 admin 中继。
- `profileId` 须为指纹数字或 UUID,否则 `400 VALIDATION_FAILED`。
## 2.5 Cookie 管理(admin 本地 Jar)
admin 数据库中按 Profile 存储的 Cookie(与节点浏览器同步的"源头")。Cookie 形状:
```json
{ "id": "uuid", "profileId": "uuid", "name": "k", "value": "v", "domain": "example.com",
"path": "/", "expiresAt": "2026-12-31T00:00:00.000Z", "secure": false, "httpOnly": false,
"sameSite": "lax", "createdAt": "...", "updatedAt": "..." }
```
| 端点 | 说明 |
|---|---|
| `GET /api/profiles/:id/cookies` | 列出 Cookie → `data: Cookie[]` |
| `POST /api/profiles/:id/cookies` | 新增单条:`{ name, value, domain, path?, expiresAt?, secure?, httpOnly?, sameSite?(strict\|lax\|none) }` → `201` |
| `PUT /api/profiles/:id/cookies` | 批量导入:数组或 `{ cookies:[...] }`(按 name+domain+path upsert)→ `{ imported: n }` |
| `DELETE /api/profiles/:id/cookies` | 清空该 Profile → `{ cleared: n }` |
| `PUT /api/cookies/:id` | 编辑单条(部分更新) |
| `DELETE /api/cookies/:id` | 删除单条 → `data: null` |
## 2.6 代理管理 `/api/proxies`
代理形状(`serializeProxy`):`{ id, name, host, port, type, username, enabled, tags[], status, checkMessage, exitIp, exitCountry, exitCity, timingMs, createdAt, updatedAt }`。
### GET /api/proxies — 列表
Query:`q`、`tag`、`country`、`sort`、`order` → `data: Proxy[]`
### POST /api/proxies — 创建
```json
{ "name": "p1", "host": "10.0.0.10", "port": 1080, "type": "socks5", "username": "", "password": "" }
// type 仅支持 socks5 | http | https,缺省 socks5 → 201
```
### POST /api/proxies/import — 批量导入
```json
{ "text": "socks5://user:pass@1.2.3.4:1080\n5.6.7.8:1080" }
// → data { created, skipped, failed: [ { line, reason } ] }
```
一行一个,支持 `protocol://user:pass@host:port` 与 `host:port`。
### GET /api/proxies/:id — 详情
### PUT /api/proxies/:id — 更新(部分字段;`password: ""` 表示清除;`tags` 为字符串数组)
### DELETE /api/proxies/:id — 删除 → `data: null`
### POST /api/proxies/:id/test — 连通性测试(真实走代理出口,geoip 归属)
```json
// data
{ "ok": true, "message": "...", "exitIp": "1.2.3.4", "exitCountry": "US", "exitCity": "...",
"timingMs": 320, "proxy": { /* 更新后的代理 */ } }
```
### POST /api/proxies/:id/toggle — 启用/禁用切换 → `data: Proxy`
## 2.7 节点管理 `/api/nodes`
节点形状(`serializeNode`):
```json
{ "name": "node-1", "ip": "1.2.3.4", "country": "US", "status": "online", "cpu": "12%", "memory": "40%",
"baseUrl": "http://1.2.3.4:3100", "publicUrl": "https://node.example.com", "version": "20260924.1010",
"maxProfiles": null, "maxMemoryPct": null, "maxCpuPct": null, "hasKey": true,
"totalProfiles": 5, "runningProfiles": 2, "createdAt": "...", "updatedAt": "..." }
```
节点不提供手动添加 —— 随 Profile 分配(`POST /api/profiles` 的 `node` 字段)或安装自注册自动创建。
### GET /api/nodes — 列表 → `data: Node[]`
### POST /api/nodes/provision — 预置节点(生成/轮换通信密钥)
```json
// 请求 { "name": "node-2" }
// data
{ "name": "node-2", "key": "pmk_...", "installCmd": "wget -qO- https://<admin>/node/install?key=..&name=node-2 | bash" }
```
### POST /api/nodes/register — 节点安装完成后自注册(免会话)
```json
// 请求
{ "name": "node-2", "key": "pmk_...", "baseUrl": "http://1.2.3.4:3100", "ip": "1.2.3.4" }
// name 与 key 必填;key 须与 provision 下发的一致 → data: Node
```
### POST /api/nodes/heartbeat — 节点心跳上报(注册 + 指标更新)
```json
// 请求
{ "name": "node-1", "ip": "1.2.3.4", "country": "US", "status": "online",
"cpu": "12%", "memory": "40%", "baseUrl": "http://1.2.3.4:3100", "version": "20260924.1010" }
```
- 认证:admin 会话/API Token,**或**该节点自己的密钥(`x-node-key` / `Bearer`,且须与 `name` 匹配)。
- `status` 仅支持 `unknown|online|offline`;`version` 为节点代码版本(v0.4 起落库)。
### PUT /api/nodes/:name — 编辑节点
```json
{ "ip": "...", "country": "US", "publicUrl": "node.example.com", // 无协议自动补 https://;空串清除(回退中继)
"status": "online", "maxProfiles": 20, "maxMemoryPct": 80, "maxCpuPct": 90 } // 配置项传 null 清除
// → data: Node
```
### DELETE /api/nodes/:name — 删除(节点上仍有 Profile 时拒绝)→ `data: null`
### POST /api/nodes/update-batch — 批量触发节点自更新
```json
// 请求 { "names": ["node-1", "node-2"] }
// data
{ "results": [ { "name": "node-1", "ok": true, "detail": "已触发" },
{ "name": "node-2", "ok": false, "detail": "节点未注册地址或无密钥" } ] }
```
逐个串行中继 `POST {节点}/system/update`(15s 超时);节点后台拉包替换后自行退出,由容器/pm2 重启生效。
## 2.8 节点代码包与自更新
### GET /node/install?key=&name= — 下发一键安装脚本
返回 bash 脚本(`text/plain`):生成 docker-compose.yml → 拉镜像 → 启动容器 → 健康检查 → 自注册。支持环境变量覆盖:`NODE_KEY`、`NODE_NAME`、`IMAGE`、`INSTALL_DIR`,参数 `-n/--name -k/--key -i/--image -b/--base-url -d/--dir`。
### GET /node/package?key= — 下载节点代码包
- 凭节点密钥;`tar.gz`(src/ + package.json + bun.lock + tsconfig.json + version)。
- 响应头:`x-package-version: <版本>`、`content-disposition: attachment; filename="serpmax-node-<版本>.tgz"`。
- 无效 key → `401 UNAUTHORIZED`。
### GET /node/version — admin 侧节点代码版本(公开)
```json
{ "success": true, "data": { "version": "20260924.1010" } }
```
> 版本号 = 源码内容指纹,`YYYYMMDD.HHMM` 日期格式,缓存 30s。节点页用 `GET /node/version` 与各节点 `GET /system/version` 比对判断是否需要更新。
## 2.9 日志 `/api/logs`
### GET /api/logs — 日志列表
Query:`q`、`profileId`、`proxyId`、`level`、`type`、`from`、`to`、`sort`、`order`、`page`、`limit`
```json
// data
{ "total": 100, "page": 1, "limit": 20, "items": [ { "id": "1", "time": "...", "level": "info",
"type": "profile.started", "message": "...", "profileId": "uuid", "proxyId": null, "metadata": {} } ] }
```
### GET /api/logs/filters — 现存的级别与类型(筛选下拉)→ `data: { levels: [...], types: [...] }`
## 2.10 系统设置 `/api/settings`
### GET /api/settings — 当前设置
```json
// data
{ "username": "admin", "apiToken": "smax_...",
"settings": { "theme": "system", "defaultRuntime": "fetch", "autoTestProxy": true,
"defaultTimeout": 30000, "systemName": "SerpMax Admin" } }
```
### PUT /api/settings — 更新(白名单键)
```json
{ "theme": "dark", "defaultRuntime": "browser", "autoTestProxy": false, "defaultTimeout": 60000, "systemName": "..." }
// → data { changed: n };无可更新项 → 400
```
### POST /api/settings/password — 修改管理员密码
```json
{ "currentPassword": "...", "newPassword": "..." } // 新密码 ≥6 位;当前密码错 → 401 INVALID_CREDENTIALS
```
### POST /api/settings/api-token/rotate — 重置 API Token → `data: { apiToken: "smax_..." }`(旧 Token 立即失效)
## 2.11 事件上报 `/api/events`
节点(或任意持 Token 方)向 admin 上报事件;`profileId` 可为 UUID 或指纹数字(自动解析)。
```json
// POST /api/events
{
"event": "captcha.detected", // 见下表
"profileId": "12345678", // 按事件类型必填
"proxyId": "uuid",
"type": "recaptcha", "kind": "cloudflare", "website": "...", "url": "...",
"level": "info", "message": "...", "status": 200, "ok": true,
"metadata": {}
}
// data 按类别:{ "recorded": "captcha" | "captcha-solved" | "browser" | "proxy" | "request" | "log" }
```
| event | 副作用 |
|---|---|
| `captcha.detected` / `recaptcha.detected` / `challenge.detected` | 记验证码事件 + 统计(`type=recaptcha` 判定:`type` 字段或事件名) |
| `captcha.solved` / `recaptcha.solved` | recaptcha 解题成功计数 |
| `browser.started` / `browser.stopped` / `browser.crashed` | Profile 状态 → running / stopped / error |
| `proxy.connected` / `proxy.failed` | 代理检测状态(必须带 `proxyId`) |
| `request.completed` | 请求统计(`ok` 优先,缺省按 `status` 2xx/3xx 判定) |
| 其他 | 仅按 `level`(debug/info/success/warn/error)记日志 |
---
# 3. Node API(端口 3100)
> Profile Manager 节点:负责指纹浏览器(Chromium/CloakBrowser)生命周期、Lease、fetch/render、打码、VNC。
> 鉴权见 [1.2](#12-认证)。`data` 字段以下均指成功信封中的内容。
## 3.1 Profile
### POST /profiles — 创建(支持 Idempotency-Key 幂等)
```json
// 请求(全部可选;对象字段只校验"必须是对象",结构由指纹导入约定决定)
{
"id": "12345678", // 外部 ID(字母数字与 -_,≤64 位);admin 导入时用指纹数字
"name": "my-profile",
"fingerprint": { "import": { /* admin BrowserFingerprint:UA/画布/字体等,导入身份以声明值为准 */ } },
"network": { "tls": { "preset": "chrome-146" }, "proxy": { /* ... */ } },
"proxy": { "id": "...", "protocol": "socks5", "host": "1.2.3.4", "port": 1080, "username": "", "password": "" },
"browser": { /* 指纹快照(同 fingerprint.import) */ },
"metadata": {}
}
// 201 → data:Profile 对象
```
Profile 状态机:`READY → IN_USE → READY`,`→ DELETING`,`ERROR →(隐式恢复)READY`。
### GET /profiles — 列表 → `data: { profiles: [...], total: n }`
### GET /profiles/:id — 详情(含当前 Lease 概要)
```json
// data
{ "id": "...", "name": "...", "state": "READY", "updatedAt": "...", /* ... */
"lease": { "leaseId": "...", "consumerId": "...", "expiresAt": "..." } } // 有 Lease 时
```
### DELETE /profiles/:id?force=true|false — 删除
流程:停浏览器 → 清 Runtime → 删数据 → 删 Profile。`force=1/true` 强制(如忽略 Lease);→ `data: { deleted: true, profileId }`。
## 3.2 Lease(租约)
同一 Profile 同时只允许一个 Consumer;浏览器操作要求持有 Lease(部分接口无 Lease 时会短期自动接管)。
### POST /profiles/:id/lease — 获取
```json
// 请求
{ "consumerId": "my-worker-1", "ttlMs": 60000 } // ttlMs ≥1000
// 201 → data
{ "leaseId": "ls_...", "profileId": "12345678", "expiresAt": "2026-09-24T10:00:00.000Z" }
```
- ⚠️ 此端点特例:参数校验失败与「正在删除」时 **HTTP 状态码为 400/409,但信封仍是 `success:true`**,错误在 `data` 内 —— `data: { error: "VALIDATION_FAILED", message: "ttlMs 必须 ≥1000" }` 或 `data: { error: "PROFILE_DELETING" }`。
- ERROR 状态自动隐式恢复为 READY 后发放。
### POST /profiles/:id/lease/:leaseId/heartbeat — 续租
```json
// 请求 { "ttlMs": 60000 } // 可选,重新计时
// data { "leaseId": "...", "profileId": "...", "expiresAt": "...", "lastHeartbeat": "..." }
```
### DELETE /profiles/:id/lease/:leaseId — 释放 → `data: { released: true, profileId, leaseId }`(Profile 回 READY,浏览器可能仍在运行)
## 3.3 浏览器生命周期
### POST /profiles/:id/browser/start — 启动浏览器(需 Lease)
请求头 `x-lease-id`(或 body `leaseId`)须匹配当前 Lease,否则 `409 LEASE_CONFLICT`。
```json
// data
{ "profileId": "12345678", "browserId": "...", "reused": false,
"cdp": { "endpoint": "http://127.0.0.1:9222", "wsEndpoint": "ws://...", "port": 9222 },
"processId": 1234 }
```
### POST /profiles/:id/browser/stop — 停止(不要求 Lease;验收流程 = 先释放 Lease 再 stop)→ `data: { profileId, status: "STOPPED" }`
### POST /profiles/:id/browser/restart — 重启
有 Lease 时校验持有者;无 Lease 时以 `admin:restart:<requestId>` 短期接管(120s),完成即释放。响应同 `start`。
### GET /profiles/:id/browser/status — 状态
```json
// data
{ "profileId": "...", "status": "RUNNING", // RUNNING | STOPPED
"browserId": "...", "cdpEndpoint": "http://...", "processId": 1234, "startedAt": "...",
"vncPort": 6100, "vncDisplay": ":100", // VNC 启用时
"vncAvailable": true,
"idleCloseInMs": 118000 } // 空闲自动关闭剩余毫秒;未启用为 null
```
### POST /profiles/:id/browser/window — 新建空白窗口(浏览器须运行中)→ `data: { url: "about:blank", windows: 3 }`
### GET /profiles/:id/browser/cdp — CDP 端点 → `data: { profileId, cdp: { endpoint, wsEndpoint, port } }`;未运行 → `409 BROWSER_NOT_RUNNING`
### GET·POST /profiles/:id/cf-solver — 自动 CF 挑战开关
- `GET` → `data: { enabled: true }`(缺省启用,随节点进程保留)
- `POST` `{ "enabled": false }` → `data: { enabled: false }`
## 3.4 浏览器操作(Action + SSE)
> 设计:HTTP 负责控制、SSE 负责状态、截图负责视觉反馈;协议细节见 `node/docs/Browser-Control.md`。
### POST /profiles/:id/browser/actions — 执行动作批次(按序,任一失败即停)
```json
// 请求
{
"actions": [
{ "type": "goto", "url": "https://www.google.com/recaptcha/api2/demo?hl=en" },
{ "type": "fill", "selector": "#email", "text": "a@b.c" },
{ "type": "click", "selector": "button.submit" }, // 或 { "x": 100, "y": 200 } 坐标点击
{ "type": "press", "key": "Control+c" },
{ "type": "wait", "ms": 1000 } // 或 { "selector": "...", "ms": 5000 }
],
"screenshotQuality": 30, // 1-100,默认 30
"timeout": 30000 // 单动作超时,默认 30000,上限 120000
}
```
支持 16 种 action:`goto · reload · back · forward · click · dblclick · hover · fill · type · press · select · check · uncheck · scroll · scrollIntoView · wait`(单次最多 30 个,详见 [附录 4.1](#41-browser-action-类型一览))。
```json
// data
{ "actionId": "act_...", "results": [ /* 每个动作的产出,按序 */ ], "clipboardText": "..." }
```
- 浏览器未运行(如空闲自动关闭后)自动重启 —— "操作即连接"。
- Lease:已持有则复用;无 Lease 时短期接管(`admin:action:<requestId>`,批次期间持有,完成即释放)。
- 每个动作的进度经 SSE `action` 事件推送(`{ actionId, status: running|completed|failed }`)。
### GET /profiles/:id/browser/events?quality=30 — SSE 事件流
- 鉴权:节点密钥或直连票据(EventSource 无法自定义头,票据在 TTL 内可复用)。
- 事件类型:`state`(url/title/loading/viewport)、`screenshot`(jpeg base64,动态 FPS 调度)、`action`(动作进度)、`error`(错误)、`browser`(`{ status:"STOPPED", reason:"browser-closed" }`,浏览器关闭时终态)。
- 15s `: ping` 保活;连接期间自动持有 Lease 并心跳,断开释放。
- 浏览器随需启动;`quality` 控制截图质量。
## 3.5 HTTP 请求执行
### POST /profiles/:id/fetch — Profile 上下文 HTTP 请求(不走浏览器)
指纹 UA/头序 + Cookie Jar + impersonate-proxy(→socks5)链路。适配器两级:优先 **go-fetch**(Go 原生客户端,`GO_FETCH_BIN` 或节点根 `bin/go-fetch`;原生 CONNECT 隧道与 MITM 完全兼容),回落系统 curl。MITM 出站连接按 (上游, 域名) 池化复用(空闲 120s/寿命 10min,借出前 PING 探活),一次性 fetch 可复用浏览器焐热的同一条出站连接,连接层指纹与浏览器一致。
```json
// 请求
{ "url": "https://httpbin.org/get", "method": "GET", "headers": { "x-demo": "1" },
"body": "...", "timeoutMs": 30000, "browserFallback": true, "rawEncoding": false } // timeoutMs 100~300000
// data:RunResult 形状(ok/status/headers/bodyText/timingMs/runtime:"fetch" 等)
// 响应体超过 FETCH_BODY_LIMIT(默认 5MB)时截断并置 truncated:true
```
**rawEncoding 压缩直通**(默认 false;v0.42.0 新增,serp-api 使用):请求强制 `accept-encoding: gzip`,
go-fetch 以 `--keep-encoding` 透传上游原始 gzip 字节——响应 `body` 为 base64、`bodyBase64:true`、
`bodyEncoding:"gzip"`,全链(Google→go-fetch→node→调用方)不解压不重压缩;上游未压缩(identity)时仍按文本返回。
仅 impersonate(go-fetch/curl)链路支持,其余链路自动降级为解压文本;此模式下不做 CAPTCHA 检测与 CF 浏览器回退。
**CF 挑战自动回退**(默认开启,仅 GET;`browserFallback:false` 关闭):响应被 Cloudflare/Turnstile 挑战拦截时,节点自动借同一 Profile 的浏览器过盾(CF 自动解题钩子生效)→ 吸收过盾 Cookie 后重试一次纯 HTTP;若站点免挑战放行浏览器、从不发 clearance(wethrift 类),则直接返回浏览器渲染内容,此时 `runtime:"browser-fallback"`、`browserSolved:true`、`status:200`。
### POST /profiles/:id/browser/fetch — 浏览器运行时请求
已运行直接 goto;未运行按需启动(复用真实指纹/代理)。两种模式:
```json
// 请求
{
"url": "https://example.com/api/data",
"runtime": "page", // 缺省 = 整页导航(browser 模式)
"method": "GET", "headers": {}, "body": "...", // 仅 page 模式使用
"timeoutMs": 45000 // 钳制 5000~120000
}
```
- `runtime:"page"`:导航到目标源(等 CF 解题放行)后在**页面 JS 里执行 fetch**,复用浏览器原生 TLS/连接池/Cookie/cf_clearance —— 适合 API 类请求。
- 默认(整页导航):返回渲染后 HTML + `title`。
- 响应含 `restarted: true|false`(本次是否触发了浏览器启动)、`finalUrl`、`timingMs`。
### POST /profiles/:id/browse — 浏览器渲染一次目标页面(同步返回)
```json
// 请求 { "url": "https://example.com", "timeoutMs": 30000 } // timeoutMs 1000~120000
// data
{ "ok": true, "status": 200, "headers": {}, "bodyText": "<html>...", "truncated": false,
"timingMs": 900, "runtime": "chromium",
"captcha": { "type": "recaptcha", "kind": "...", "website": "...", "url": "..." } }
```
- Lease:已持有则复用;否则临时获取(`admin:browse:<requestId>`),结束释放,并新开页渲染后关闭。
- 检出验证码时发布 `captcha.detected` / `challenge.detected` / `blocked.detected` 事件。
- 超时 → `504 FETCH_TIMEOUT`;证书/代理错误 → `502 PROXY_FAILED`。
## 3.6 Cookie(node 运行时)
操作**浏览器中的 Cookie**(运行中经 Playwright Context),并与 fetch 链路的 Cookie Jar 双向同步;浏览器未运行时仅操作 Jar。形状:`{ id, profileId, name, value, domain, path, expiresAt, secure, httpOnly, sameSite }`。
| 端点 | 说明 |
|---|---|
| `GET /profiles/:id/cookies` | 浏览器运行中 → `context.cookies()`(过滤同步黑名单);未运行 → Jar |
| `PUT /profiles/:id/cookies` | 覆盖导入:数组或 `{cookies:[...]}`;运行中 addCookies + Jar,未运行仅 Jar → `{ imported, browser, deferred }`(`deferred:true` 表示浏览器侧留待下次启动) |
| `DELETE /profiles/:id/cookies` | 浏览器 clearCookies + 清 Jar → `{ cleared: true }` |
`expiresAt` 兼容 ISO 字符串与秒/毫秒时间戳;`sameSite ∈ strict|lax|none`。
### GET·PUT /profiles/:id/cookie-filter — Cookie 同步黑白名单
```json
// GET → data { "whitelist": [], "blacklist": ["ads.example.com"] } // 空 = 不限
// PUT 请求同形状 → data { whitelist, blacklist, pruned: 3 } // 立即剔除 Jar 中不允许同步的存量(浏览器 USER_DATA 不动)
```
只影响"浏览器 ↔ Jar / admin 视图"的同步方向,浏览器本地 Cookie 不变。
## 3.7 缓存管理
### GET /profiles/:id/cache — 磁盘用量
```json
// data
{ "profileId": "...", "totalBytes": 10485760, "cacheBytes": 8388608, "storageBytes": 2097152, "downloadsBytes": 0 }
```
### POST /profiles/:id/cache/clear — 清理
```json
// 请求 { "mode": "soft" } // soft=清缓存目录(cache);hard=整目录重置(含 storage,浏览器须停止)
// soft → data { profileId, mode: "soft", bytesFreed: 8388608 }
// hard → data { profileId, mode: "hard", bytesFreed: null }
// 其他值 → 400 VALIDATION_FAILED
```
## 3.8 Schema 与 Execution
Schema = 声明式自动化流程(版本化,禁止覆盖只能递增)。
### POST /schemas — 创建新版本 → `201`,data:Schema 对象
### GET /schemas/:id — 读取
- `?version=N` 指定版本(缺省最新);`?versions=1` → `data: { id, versions: [...] }`
### DELETE /schemas/:id — 删除(全部版本;运行中的版本阻止删除)→ `data: { deleted: true, schemaId }`
### POST /profiles/:id/schema/:schemaId/run — 执行 Schema
```json
// 请求
{ "version": 3, "variables": { "keyword": "test" }, "leaseId": "ls_..." } // 均可选;leaseId 也可用 x-lease-id 头
// 202 → data
{ "executionId": "exec_...", "profileId": "...", "schemaId": "...", "version": 3, "status": "RUNNING" }
```
Schema 内 `captcha.autoSolve` 声明会自动触发 reCAPTCHA 看门狗注入。
### GET /executions/:id — 查询执行记录(状态/步骤结果)
### POST /executions/:id/cancel — 取消 → `data: { executionId, cancelRequested: true|false }`
## 3.9 reCAPTCHA 打码
打码会话由显式端点(答题面板 / autosolve)创建;`state` 是只读端点,不会触发注入。
| 端点 | 方法 | 说明 |
|---|---|---|
| `/profiles/:id/captcha/state` | GET | 组合状态:手动 mapper 状态 + 自动求解器摘要;未连接会话 → `{ status:"absent", message, solver }` |
| `/profiles/:id/captcha/events` | GET | SSE:状态推送(连接即创建会话,15s ping) |
| `/profiles/:id/captcha/image` | GET | 主图 jpeg(无图 → 404 `no image`) |
| `/profiles/:id/captcha/tile/:idx` | GET | 第 idx 格子图 jpeg |
| `/profiles/:id/captcha/start` | POST | 点击 checkbox 拉起挑战 |
| `/profiles/:id/captcha/adopt` | POST | 采纳页面上已打开的挑战 |
| `/profiles/:id/captcha/click` | POST | `{ "index": 3 }` 点格子,或 `{ "nx": 0.5, "ny": 0.5 }` 归一化坐标 |
| `/profiles/:id/captcha/verify` | POST | 提交验证 |
| `/profiles/:id/captcha/reload` | POST | 换一题 |
| `/profiles/:id/captcha/action` | POST | `{ "kind": "next" \| "skip" \| "verify" }`(缺省 verify) |
| `/profiles/:id/captcha/autosolve` | POST | 触发自动求解(立即返回,结果轮询 status);页面无挑战 → `{ started:false, reason:"no-challenge" }`;求解中幂等忽略 |
| `/profiles/:id/captcha/status` | GET | 自动求解器状态:`{ solving, result, solvedToken }` |
| `/profiles/:id/captcha/detach` | POST | 丢弃会话 → `{ disposed: true }` |
## 3.10 VNC 与直连
### WS /profiles/:id/vnc/ws — noVNC WebSocket 桥(→ 本机 x11vnc TCP)
- 握手即升级;鉴权:节点密钥或一次性直连票据(`?ticket=`)。
- Query `cfAuto=1|0`:连接时设置「自动 CF 挑战」开关(缺省不动)。
- 浏览器未运行或 VNC 未启用 → 404 信封(升级前)。
- 客户端真实操作(RFB KeyEvent / 带按键 PointerEvent / ClientCutText)会重置浏览器空闲计时。
### POST /profiles/:id/direct-ticket — 签发直连票据(仅节点密钥持有者)
→ `data: { ticket: "dtk_...", expiresInMs: 300000 }`。票据绑定 Profile、一次性(actions)短时可复用(events/VNC)、5 分钟过期。
### POST /profiles/:id/vnc/paste — VNC 粘贴
```json
// 请求 { "text": "hello" }
// 纯 X 路径:xsel 写 CLIPBOARD + x11vnc 注入真实 Ctrl+V,落点为 VNC 当前焦点
// data { "pasted": 5, "mode": "x11" }
```
VNC 未启用 → `409 BROWSER_NOT_RUNNING`;缺 xsel → `500 INTERNAL_ERROR`。
### POST /profiles/:id/vnc/restart — VNC 重启/恢复
x11vnc 重启(色深切换清残留);实例丢失时自动整组重建(浏览器不动)。
→ `data: { restarted: true, recovered: false, port: 6100 }`;无法恢复 → `409 BROWSER_NOT_RUNNING`。
## 3.11 系统
| 端点 | 方法 | 说明 / data |
|---|---|---|
| `/health` | GET | `{ status:"ok", uptimeMs }` |
| `/ready` | GET | 就绪检查:`{ ready, checks: { profileCache, runtimeManager, chromiumBinary, recoveryDone } }`;未就绪整体 503 |
| `/metrics` | GET | Prometheus 文本(`text/plain; version=0.0.4`),非 JSON 信封 |
| `/events?limit=&profileId=` | GET | 最近事件环形缓冲:`{ events: [...], total }`(调试) |
| `/browsers` | GET | `{ running: n }` 当前运行浏览器数(调试) |
| `/node/info` | GET | 节点自述(与心跳同构):`{ name, ip, country, status:"online", version, admin:{enabled,url,eventForward,heartbeatInterval}, profiles:{total,running}, browsersRunning }` |
| `/system/version` | GET | `{ version: "20260924.1010" }`(30s 缓存) |
| `/system/update` | POST | 自更新:立即 `{ started:true, from:"<旧版本>" }`;后台拉包(admin `/node/package`)→ 替换 /app → `bun install` → `process.exit(0)` → 容器 `restart: unless-stopped` 载入新代码。未配置 `ADMIN_URL` → `400 { started:false, reason:"ADMIN_URL 未配置" }` |
---
# 4. 附录
## 4.1 Browser Action 类型一览
单次请求最多 **30** 个 action;按数组顺序执行,任一失败即停止后续。前置校验快速失败:
| type | 必填/常用参数 | 说明 |
|---|---|---|
| `goto` | `url`(http/https) | 导航 |
| `reload` | — | 刷新 |
| `back` / `forward` | — | 历史前进/后退 |
| `click` | `selector` 或 `x`+`y`(视口 CSS 像素) | 点击 |
| `dblclick` | `selector` | 双击 |
| `hover` | `selector` | 悬停 |
| `fill` | `selector`、`text` | 清空并填入 |
| `type` | `selector`、`text` | 逐键输入 |
| `press` | `key`(如 `Enter`、`Control+c`) | 按键;`Control+c` 的剪贴板结果经 `clipboardText` 返回 |
| `select` | `selector`、`value(s)` | 下拉选择 |
| `check` / `uncheck` | `selector` | 勾选/取消 |
| `scroll` | `x`/`y` 或 `selector` | 滚动 |
| `scrollIntoView` | `selector` | 滚动到元素 |
| `wait` | `ms` 或 `selector` | 等待时长/元素出现 |
请求级参数:`screenshotQuality`(1-100,默认 30)、`timeout`(单动作超时 ms,默认 30000,上限 120000)。
错误码:`INVALID_ACTION`(类型非法/超数)、`INVALID_ACTION_PARAMETER`(参数缺失)、`ELEMENT_NOT_FOUND`、`ELEMENT_NOT_VISIBLE`、`ACTION_TIMEOUT` 等,见 [1.4](#14-错误码表)。
## 4.2 SSE 事件格式
`GET /profiles/:id/browser/events` 每条事件:
```
event: state
data: {"url":"https://...","title":"...","loading":false,"viewport":{"w":1440,"h":900,"dpr":1}}
event: screenshot
data: {"jpeg":"<base64>","w":1440,"h":900}
event: action
data: {"actionId":"...","status":"completed"}
event: browser
data: {"status":"STOPPED","reason":"browser-closed"}
```
- 注释行 `: ping`(15s)保活;空闲超时 120s(服务端)。
- `GET /profiles/:id/captcha/events` 同为 SSE,`data` 为打码状态对象(带 `solver` 摘要)。
## 4.3 典型调用流程
**自动化消费方(节点直连):**
```bash
BASE=http://127.0.0.1:3100
K="x-node-key: $NODE_KEY"
# 1. 创建 Profile(幂等)
curl -s -X POST $BASE/profiles -H "$K" -H "idempotency-key: job-42" \
-H "content-type: application/json" -d '{"id":"12345678","name":"demo"}'
# 2. 取 Lease
curl -s -X POST $BASE/profiles/12345678/lease -H "$K" -H "content-type: application/json" \
-d '{"consumerId":"worker-1","ttlMs":60000}'
# → data.leaseId
# 3. 启动浏览器(拿 CDP)
curl -s -X POST $BASE/profiles/12345678/browser/start -H "$K" -H "x-lease-id: <leaseId>" -d '{}'
# 4. 执行动作
curl -s -X POST $BASE/profiles/12345678/browser/actions -H "$K" \
-H "content-type: application/json" \
-d '{"actions":[{"type":"goto","url":"https://example.com"}],"screenshotQuality":60}'
# 5. 释放 Lease、停浏览器
curl -s -X DELETE $BASE/profiles/12345678/lease/<leaseId> -H "$K"
curl -s -X POST $BASE/profiles/12345678/browser/stop -H "$K"
```
**admin 中继(前端):** 登录(`POST /api/auth/login`)→ `GET /api/profiles/:id/direct-session` 拿 `{ nodeUrl, ticket }` → 前端直连 `wss://<nodeUrl>/profiles/<指纹>/vnc/ws?ticket=<ticket>`(或全程走 admin 中继 `/api/profiles/:id/vnc/ws`、`/browser-actions`、`/browser-events`)。
**节点部署:** admin 节点页 `POST /api/nodes/provision` → 复制 `installCmd` 到目标机执行 → 脚本自动 docker compose 启动 + `POST /api/nodes/register` → 之后节点定时 `POST /api/nodes/heartbeat`;升级时 admin 调 `POST /api/nodes/update-batch`。
+130
View File
@@ -0,0 +1,130 @@
# ProfileMax「发布」(Publishing) 功能扩展开发说明
> 目标:供 Zcode 在现有 SerpMax/ProfileMax 项目中直接扩展。新增一个「发布」一级菜单,页面内部使用 Ant Design Tabs(页签)管理子功能。此处“Tags”按页面 Tabs 理解;站点标签另用 tags 字段,不要混淆。
> 依据:`docs/审计-功能清单与技术栈.md`(2026-10-28),开发前以当前源码为准。
## 1. 定位和边界
- **只扩展现有 ProfileMax**:复用 Bun.serve、PostgreSQL/Bun.sql、React 19 + antd 6、Scripts、Tasks、54 个 MCP 工具、Profile/Proxy/Cookie、Node/Lease、Playwright、Fetch 和 WebDesk。
- **不做独立平台、不集成 LLM/Agent 服务**。Zcode/Codex/OpenCode 通过 MCP + Skills 负责网站学习、文章生成、脚本编写与失败修复。ProfileMax 负责确定性执行、持久化、校验、调度、监控和评分。
- 网站资源由用户 CSV/JSON/API 导入,不实现自动发现/爬取资源引擎;仅支持有授权或允许投稿的网站,遵守站点规则。
- V1 不增加消息队列、微服务、第二套调度器;优先扩展现有 tasks、scripts 和 task_runs。现有脚本/任务功能必须保持兼容。
## 2. UI:一个「发布」菜单 + 内部 Tabs
导航增加 **发布 (Publishing)** 一级菜单,点击后页面顶部用 Ant Design `Tabs` 切换:
| Tab | 最小功能 |
| --- | --- |
| 项目 | 项目 CRUD;官网、产品资料、目标 URL、关键词、内容规则、默认语言 |
| 站点 | CSV/JSON 导入、列表/标签/筛选、站点 URL、平台类型、允许发布的账号/Profile 关联、脚本版本、状态 |
| 文章 | 草稿导入/编辑(Markdown/HTML)、文章版本、审核状态、目标链接/锚文本;内容由外部 Agent 生成后保存 |
| 发布任务 | 创建发布任务、选择文章/站点/Profile、脚本、执行时间/并发、运行日志、失败诊断、手动重试 |
| 质量与评分 | 发布 URL、链接属性、链接存活检查、最近检测时间、站点评分/外链评分和历史结果 |
- 页面沿用当前布局、路由、权限、组件、暗色主题及 API 封装方式;图标使用 lucide-react,不使用 Emoji。
- Tabs 的选择应支持 URL 定位(刷新保留),权限统一使用新增 `publishing` 菜单权限,并校验关联 Profile 的所有权/分享权限。
- “发布”是一级菜单,其下使用 **Tabs 而非再添加五个左侧菜单**。站点本身的业务 `tags` 用于分类筛选,与 UI Tabs 是两种概念。
## 3. 执行流程
1. 用户/Agent 导入站点并与现有 Profile 关联,记录网站可发布权限、平台类型及标签。
2. 外部 Agent 通过已有 MCP/browser tools 学习站点,优先复用已有 `schema` 脚本,复杂页面才使用 `code`;第一次或少量测试后保存稳定脚本并绑定站点(或平台)。
3. 外部 Agent 根据项目资料生成有实际价值的文章,通过 MCP/REST 写入「文章」,标记 `draft/review/ready`。
4. Publishing 生成发布作业并交给**现有任务系统**执行。文章正文、标题、图片、链接作为每次运行的输入参数;不为每篇文章复制脚本。执行时使用已有 Profile Lease。
5. 将结构化输出(发布 URL、状态、错误码)与 task_run 关联,提交超时标记 `unknown`,先查是否已发布再重试,避免重复提交。
6. 失败时保存**脱敏**错误、失败步骤、截图/DOM 摘要;将修复状态标记 `needs_repair`,外部 Agent 通过 MCP 查询、测试并提交新脚本版本。V1 允许 Agent 主动轮询,不要求 ProfileMax 自动启动 Agent。
7. 发布后由后台定时进行 HTTP/HTML 检查;需要登录/JS 渲染才回退现有 Profile Fetch/Browser。定期计算评分,供站点筛选使用。
### 标准脚本 I/O(新增适配层,不破坏现有脚本 API)
```typescript
interface PublishInput {
siteId: string;
article: {
title: string;
html: string;
markdown?: string;
images?: string[];
};
targetLinks: Array<{ url: string; anchor: string }>;
mode: "draft" | "publish";
}
interface PublishResult {
status: "draft" | "published" | "failed" | "unknown";
articleUrl?: string;
externalId?: string;
errorCode?: string;
message?: string;
}
```
任务需要 `idempotencyKey`、脚本版本快照和稳定的 run ID;不能因重试发布重复文章。
## 4. 数据模型(尽量少表,沿用现有数据库)
新增前先检查已有数据结构,避免重复:
| 表 | 核心字段 |
| --- | --- |
| `publishing_projects` | id, owner_user_id, name, website, config(jsonb), created_at |
| `publishing_sites` | id, project_id, url, platform, tags(text[]), profile_id(nullable), script_id(nullable), status, metadata(jsonb) |
| `publishing_articles` | id, project_id, title, markdown, html, target_links(jsonb), version, status, metadata(jsonb) |
| `publishing_jobs` | id, project_id, site_id, article_id, profile_id, task_id(nullable), script_version, idempotency_key, status, result(jsonb) |
| `publishing_checks` | id, job_id, checked_at, page_status, link_status, rel(jsonb), detail(jsonb) |
- 与现有 `profiles`/`scripts`/`tasks`/`task_runs` 使用关联 ID,不复制 Cookie、Proxy 或完整执行日志。
- V1 脚本版本可增加 `script_versions` 通用表及稳定版本指针;禁止直接覆盖正在运行的版本。版本表应是通用 Scripts 能力,不放在 Publishing 业务表中。
- 评分先用查询/视图或定期计算,不必新建评分历史表;Site Score 建议结合主题相关性、成功率、链接保留率、成本;Backlink Score 包含链接存活、属性、内容相关性及可获得的引荐效果。未知数据标记 unknown,不编造指标。
- 迁移延续现有 PostgreSQL 迁移惯例;新增索引(owner/project/site/job/status/idempotency_key);新写接口校验访问权限、输入及关联实体归属。
## 5. REST / MCP / Skills
REST API 建议统一前缀 `/api/publishing`:
- `GET/POST /projects`、`GET/PATCH/DELETE /projects/:id`
- `GET/POST /sites`、`POST /sites/import`、`PATCH /sites/:id`
- `GET/POST /articles`、`PATCH /articles/:id`
- `GET/POST /jobs`、`GET /jobs/:id`、`POST /jobs/:id/retry`
- `POST /jobs/:id/verify`、`GET /jobs/:id/checks`、`GET /scores`
MCP **复用** `admin/src/api/mcp.ts` 的认证与工具框架;V1 只补充少量组合工具:
- `publishing_import_sites`
- `publishing_save_article`
- `publishing_create_job`
- `publishing_get_job`
- `publishing_verify_job`
- `publishing_get_repair_context`
脚本 CRUD/运行尽量调用现有 MCP,不重复暴露同名能力。新增 `skills/profilemax-publishing/SKILL.md` 及 references,写明:导入站点、学习站点、复用/生成脚本、测试、生成内容、创建任务、查询失败、修复测试、验证链接。Skills 是外部 Agent 的操作说明,不在服务端执行大模型。
## 6. 代码落点与实现要求
- 前端:`admin/frontend/pages/Publishing.tsx`;接入现有路由/导航;内部 Tabs 使用独立子组件,避免一个超大文件。
- API:`admin/src/api/routes/publishing.ts`;保持现有 `Bun.serve` 路由模式、身份与权限中间件。
- DB:`admin/src/repos/publishing*.ts` 与现有迁移文件。
- 业务:`admin/src/runtime/publishing/` 负责作业映射、定时监控和评分;**复用** `admin/src/runtime/taskScheduler.ts`,不要写第二套 scheduler。
- Node:优先不改节点;只有在脚本 I/O 或故障证据无法通过现有 Schema 执行器实现时,才最小修改 `node/src/core/schema/`。
- MCP:`admin/src/api/mcp.ts` 增加工具,继续使用同一 Bearer 认证与权限校验。
- 现有 `code` 脚本通过 `new Function` 在节点进程执行、没有沙箱:**不得**让 Agent 新生成的任意代码直接进入该运行通道。V1 优先 Schema;如果必须支持 Agent 生成 JS/TS,需先实现真正的低权限独立进程/容器隔离(不是 `node:vm`),并限制敏感文件、进程、网络及密钥访问。Agent 输出和网页 DOM 一律视为不可信。
- 站点/脚本修复只提交候选版本,经测试和审查再发布 stable;不能在生产中直接修改热脚本。
## 7. 开发顺序与验收
**阶段 A(最小可用)**:发布菜单+Tabs;项目/站点/文章 CRUD;导入站点/关联 Profile;MCP 读写;复用现有稳定脚本创建任务并显示结果。
**阶段 B(脚本复用)**:参数化脚本 I/O、通用版本管理、失败证据、待修复状态、外部 Agent 的学习/测试/修复 Skill。完成代码执行隔离前,不允许自动运行不可信 Agent 生成的 code 脚本。
**阶段 C(质量)**:定时外链验证、历史检查、站点评分及汇总报表。
必须验证:
1. 发布菜单仅一个一级入口,内部五个 Tabs 切换和刷新定位正常;多用户权限正确。
2. CSV/JSON 批量导入可去重,同项目站点可筛选、打标签、关联获授权的 Profile。
3. 已有 Schema/脚本可复用,文章不同但不复制脚本;运行成功返回文章 URL。
4. 失败、取消、超时、重复投递安全处理,重复任务不会导致重复发布。
5. Agent 离线时正常任务与质量监控照常运行;失败待修复可通过 MCP 领取和诊断。
6. HTTP 检测正常,动态/认证页面有回退方案;评分无数据时展示未知或样本数。
7. 原有 Profiles、Scripts、Tasks、SERP、MCP、WebDesk 功能均通过回归测试。
**Zcode 开发原则:先检查现有源码/接口与表结构,再最小增量改造;禁止重复造已有模块。本文是实施目标,不代表接口或表已经存在。**
+489
View File
@@ -0,0 +1,489 @@
# Chromium Extension Bind Mount 最小实现
> **[2026-10-01] 本方案已被「静默安装」Policy 取代,仅作历史设计存档。**
> 用户定版:不再坚持插件文件共享。插件改为每 Profile 静默安装
> (External Extensions + CRX),扩展文件自然落在各 Profile 内部,
> 且 install 事件每 Profile 只发一次、磁盘与装载语义均优于 bind mount。
> 实测结论与现行实现见 `extension-install-events-findings.md` §7 与
> `node/src/core/browser/extension.silent.ts`。
## 1. 目标
在 Linux + CloakBrowser/Chromium 环境中,让多个独立 Browser Profile 共用同一份第三方 Extension 文件,减少每个 `USER_DATA` 重复保存 Extension 代码。
目标结构:
```text
共享 Extension
/opt/serpmax/extensions/<extension-id>/<version>/
|
+---- bind mount ---- Profile 001/UserData/Default/Extensions/<id>/<version>
+---- bind mount ---- Profile 002/UserData/Default/Extensions/<id>/<version>
+---- bind mount ---- Profile 003/UserData/Default/Extensions/<id>/<version>
```
每个 Browser 仍然拥有独立的 `USER_DATA`、Cookie、Preferences、Extension State 等运行状态;只共享 Extension 代码目录。
本方案不修改 Chromium/CloakBrowser 源码。
## 2. 适用范围
- Linux
- Chromium/CloakBrowser
- 第三方 Extension,不修改 Extension 源码
- 多 Browser Profile
- Extension 代码版本固定
- 允许使用 root 或具备 mount 权限的服务执行 `mount --bind`
不使用:
- `--load-extension`
- CloakBrowser `extension_paths`
- 每个 Profile 复制一份 Extension 代码
## 3. 目录规划
示例:
```text
/opt/serpmax/
├── extensions/
│ └── abcdefghijklmnopabcdefghijklmnop/
│ └── 1.0.0/
│ ├── manifest.json
│ ├── background.js
│ ├── content.js
│ └── ...
└── profiles/
├── 001/
│ └── UserData/
│ └── Default/
│ └── Extensions/
│ └── abcdefghijklmnopabcdefghijklmnop/
│ └── 1.0.0/ # bind mount
└── 002/
└── UserData/
└── Default/
└── Extensions/
└── abcdefghijklmnopabcdefghijklmnop/
└── 1.0.0/ # bind mount
```
建议共享目录设置为只读,避免某个 Browser 修改共享 Extension 文件影响其它 Browser。
## 4. 前置条件
先准备一份完整的 Extension 文件:
```bash
SHARED="/opt/serpmax/extensions/abcdefghijklmnopabcdefghijklmnop/1.0.0"
mkdir -p "$SHARED"
# 将已经确认可用的 Extension 文件放入 $SHARED
test -f "$SHARED/manifest.json"
```
检查版本:
```bash
cat "$SHARED/manifest.json" | grep '"version"'
```
Extension ID、目录名和版本目录必须与 Chromium 实际使用的 Extension 路径一致。
## 5. 为单个 Profile 创建 bind mount
假设:
```bash
EXT_ID="abcdefghijklmnopabcdefghijklmnop"
VERSION="1.0.0"
PROFILE="/opt/serpmax/profiles/001/UserData"
SRC="/opt/serpmax/extensions/$EXT_ID/$VERSION"
DST="$PROFILE/Default/Extensions/$EXT_ID/$VERSION"
```
创建目标父目录:
```bash
mkdir -p "$(dirname "$DST")"
```
目标目录第一次不能已经是普通文件目录。先确认:
```bash
ls -ld "$DST" 2>/dev/null || true
```
如果是 Chromium 已经复制出来的普通目录,需要先停止 Browser,再删除该目录:
```bash
rm -rf "$DST"
```
创建空目标目录:
```bash
mkdir -p "$DST"
```
执行 bind mount:
```bash
mount --bind "$SRC" "$DST"
```
建议再设置只读:
```bash
mount -o remount,bind,ro "$DST"
```
检查:
```bash
mountpoint "$DST"
findmnt "$DST"
```
## 6. 多 Profile
每个 Profile 都执行一次:
```bash
for ID in 001 002 003 004; do
PROFILE="/opt/serpmax/profiles/$ID/UserData"
DST="$PROFILE/Default/Extensions/$EXT_ID/$VERSION"
mkdir -p "$(dirname "$DST")"
if mountpoint -q "$DST"; then
continue
fi
rm -rf "$DST"
mkdir -p "$DST"
mount --bind "$SRC" "$DST"
mount -o remount,bind,ro "$DST"
done
```
## 7. Profile 生命周期与启动顺序
Bind mount 的生命周期跟随 **Profile / USER_DATA**,不跟随 Browser 进程。
正确流程:
```text
创建 Profile
|
+-- 创建 USER_DATA
|
+-- 创建 Extension bind mount
|
+-- 启动 CloakBrowser
|
+-- Browser 正常运行
|
+-- Browser 退出
| |
| +-- 保留 USER_DATA
| +-- 保留 bind mount
|
+-- 下次启动
| |
| +-- 直接复用已有 bind mount
|
+-- 删除 Profile
|
+-- 确认 Browser 已退出
+-- umount
+-- 删除 USER_DATA
```
核心原则:
**先 mount,再启动 Browser;Browser 退出不卸载;只有删除 Profile 时才卸载。**
不要在 Browser 已经启动并加载 Extension 后动态替换目录。
## 8. 启动前校验
启动 Browser 前至少检查:
```bash
test -f "$SRC/manifest.json" || exit 1
mountpoint -q "$DST" || exit 1
test -f "$DST/manifest.json" || exit 1
```
确认实际文件来自共享目录:
```bash
readlink -f "$DST/manifest.json"
```
bind mount 下这里看到的是目标路径,但文件内容应与:
```bash
"$SRC/manifest.json"
```
一致。
## 9. 删除 Profile 时卸载
Browser 退出后**不卸载** bind mount。
只要 Profile / USER_DATA 仍然存在,就保持 bind mount,这样下次启动 Browser 可以直接复用。
删除 Profile 时,必须先确认 Browser 已经完全退出,再执行卸载:
```bash
if mountpoint -q "$DST"; then
umount "$DST"
fi
rm -rf "$PROFILE"
```
如果存在多个 Extension,需要逐个卸载后再删除 USER_DATA:
```text
删除 Profile
|
+-- Browser 是否已退出?
|
+-- umount 所有 Extension bind mount
|
+-- 删除 USER_DATA
```
**不要在 Browser 运行期间删除 USER_DATA,也不要直接 rm -rf 包含活动 mount point 的 USER_DATA。**
## 10. 关键实现:Profile Manager
SerpMax 后续可以只实现三个函数:
```text
mountExtension(profileId, extensionId, version)
unmountExtension(profileId, extensionId, version)
isExtensionMounted(profileId, extensionId, version)
```
伪代码:
```text
mountExtension():
1. 定位共享 Extension
2. 检查 manifest.json
3. 定位 Profile Extension 目录
4. 检查 Browser 是否运行
5. 删除原来的版本目录
6. 创建空目录
7. mount --bind
8. remount,bind,ro
9. 校验 mount
10. 返回成功
unmountExtension():
1. 检查 mountpoint
2. 确认 Browser 已退出
3. umount
4. 删除 Profile 时再删除 USER_DATA 中对应目录
```
## 11. 最小 Shell 实现
可以先使用下面脚本验证整个方案:
```bash
#!/usr/bin/env bash
set -euo pipefail
EXT_ID="abcdefghijklmnopabcdefghijklmnop"
VERSION="1.0.0"
SRC="/opt/serpmax/extensions/$EXT_ID/$VERSION"
PROFILE="/opt/serpmax/profiles/001/UserData"
DST="$PROFILE/Default/Extensions/$EXT_ID/$VERSION"
test -f "$SRC/manifest.json"
mkdir -p "$(dirname "$DST")"
if mountpoint -q "$DST"; then
echo "already mounted: $DST"
exit 0
fi
if [ -e "$DST" ]; then
rm -rf "$DST"
fi
mkdir -p "$DST"
mount --bind "$SRC" "$DST"
mount -o remount,bind,ro "$DST"
mountpoint "$DST"
echo "mounted: $SRC -> $DST"
```
## 12. 验证测试
### 12.1 Mount 是否成功
```bash
findmnt "$DST"
```
应该能看到 bind mount。
### 12.2 文件是否一致
```bash
sha256sum "$SRC/manifest.json"
sha256sum "$DST/manifest.json"
```
两个 Hash 应一致。
### 12.3 多 Profile 是否共用文件
分别执行:
```bash
stat "$SRC/manifest.json"
stat "/opt/serpmax/profiles/001/UserData/Default/Extensions/$EXT_ID/$VERSION/manifest.json"
stat "/opt/serpmax/profiles/002/UserData/Default/Extensions/$EXT_ID/$VERSION/manifest.json"
```
确认所有 Profile 都指向同一个共享源目录。
### 12.4 Extension 生命周期测试
使用正常安装方式让 Chromium 识别 Extension,不使用:
```text
--load-extension
extension_paths
```
然后测试:
1. 第一次启动 Profile。
2. 检查 Extension 是否正常运行。
3. 记录 `chrome.runtime.onInstalled`。
4. 关闭 Browser。
5. 再次启动同一个 Profile。
6. 确认不会因为每次启动都重新作为 command-line unpacked extension 加载而触发 `reason=install`。
注意:bind mount 本身不会改变 Chromium 的 Extension 生命周期。它只解决文件存储层共享问题。要避免 `--load-extension` 每次启动产生的 install 行为,必须使用正常的 Extension 安装/注册方式。
## 13. 重要限制
### 13.1 不要多个 Browser 同时写共享目录
多个 Browser 使用同一份 Extension 代码时,共享目录必须视为只读资源。
推荐:
```text
Extension Code 共享、只读
Extension State 每 Profile 独立
Cookies 每 Profile 独立
Preferences 每 Profile 独立
Secure Preferences 每 Profile 独立
Local Storage 每 Profile 独立
Service Worker State 每 Profile 独立
```
### 13.2 Extension 更新
不要直接覆盖正在使用的版本目录。
推荐:
```text
1. 新版本写入新的共享目录
2. 停止相关 Browser
3. 更新 Profile 的 bind mount
4. 创建新版本 bind mount
5. 启动 Browser
如果不需要立即切换版本,也可以等 Profile 下次启动前更新 mount。
```
例如:
```text
extensions/<id>/1.0.0/
extensions/<id>/1.1.0/
```
不要让 Browser 运行期间直接修改:
```text
extensions/<id>/1.0.0/
```
### 13.3 权限
执行 `mount --bind` 通常需要 root 或 CAP_SYS_ADMIN。
SerpMax 如果本身不是 root 运行,建议单独提供一个最小权限的 mount helper,而不是让整个应用拥有完整 root 权限。
## 14. 最小生产结构
第一阶段不需要复杂的 Mount Manager:
```text
SerpMax
|
+-- Extension Registry
| |
| +-- extension-id
| +-- version
| +-- shared-path
|
+-- Profile Manager
| |
| +-- profile-id
| +-- user-data-path
|
+-- Mount Manager
|
+-- mount
+-- unmount
+-- status
```
Mount Manager 只负责文件系统 Mount,不负责 Extension 生命周期、Cookie 或 Browser 状态。
## 15. 实施结论
本阶段只实现:
- 一个共享 Extension 目录
- 一个 Profile 一个 bind mount
- Profile 创建/初始化时完成 mount
- Browser 退出后保持 mount
- 删除 Profile 时才 unmount
- 共享 Extension 目录只读
- Profile 状态保持独立
- 不修改 Chromium/CloakBrowser 源码
先用单 Extension + 2 个 Profile 验证:
```text
Shared Extension
|
+-- Profile 001
|
+-- Profile 002
```
确认 Extension 正常运行、两个 Profile 状态独立、`USER_DATA` 不再保存重复 Extension 文件后,再扩展到多 Extension / 多 Profile。
+311
View File
@@ -0,0 +1,311 @@
# 插件加载实测:onInstalled("install") 触发机制与 bind mount 方案可行性
本文是 `docs/extension-bind-mount-minimal.md` 的实测补充与结论。
全部结论均在**生产同款浏览器 CloakBrowser 146.0.7680.177** 上实测得到
(不是 Playwright 自带的 Chromium 153),可用 `node/scripts/probe-install-modes.ts` 复现。
## 1. 目标
1. 按 bind mount 文档重构插件加载方式(共享扩展代码目录,不再每 Profile 复制一份)。
2. 开发一个小插件,测试**每次浏览器启动是否都会触发 `onInstalled("install")`**。
3. 目标:让 `install` **只在首次触发**。
## 2. 实测环境
| 项 | 值 |
|---|---|
| 浏览器 | `/home/bigcode/data/.cloakbrowser/chromium-146.0.7680.177.5/chrome`(CloakBrowser 146) |
| 启动方式 | 直接 `spawn` 真实二进制 + `--headless=new`(**不走 Playwright**,原因见文末) |
| 探针扩展 | `node/tests/fixtures/probe-extension/`(MV3,`key` 内嵌固定公钥,ID 恒为 `lhfmmbkophlpeoebdjpgfampblhkdmjg`) |
| 观测方式 | CDP 在扩展 service worker 上下文求值 `chrome.storage.local.get(['installs','boots'])` |
探针扩展把**每次** `onInstalled` 追加写入 `chrome.storage.local.installs`,并用 `boots` 计数
区分「扩展被加载」与「install 事件触发」——这两者不是一回事,必须分开看。
> 踩过的坑:第一版探针用 `fetch` 上报到本地收集器,结果 146 上被策略拦截,读数恒为 0,
> 一度误判成「扩展没加载」。**观测必须走扩展自己的 storage,不能走网络。**
## 3. 核心实测结果
### 3.1 现状(`--load-extension`,即 cloakbrowser 的 `extensionPaths`)
```
launch 1: installs=1 boots=1 reasons=["install"]
launch 2: installs=2 boots=2 reasons=["install"]
launch 3: installs=3 boots=3 reasons=["install"]
```
**每次启动都触发 `onInstalled(reason="install")`,3 次启动 3 次事件。**
原因在 Chromium 内部写得很清楚:这种扩展在 `Default/Preferences` 里记为
```
location = 8 (COMMAND_LINE)
creation_flags = 38
path = <共享解压目录>
disable_reasons = []
```
`location=8` 意味着它**与命令行绑定**:每次启动命令行都重新「安装」一遍,故 `install` 必然重发。
这是 `--load-extension` 的固有语义,不是 bug。
### 3.2 替代路线逐条实测
| # | 方案 | install 次数 | 扩展是否加载 | 关键证据 |
|---|---|---|---|---|
| 1 | bind mount 到 `Default/Extensions/<id>/<version>`,启动不带任何扩展参数 | 0 | **否** | 目录里文件在(只读、hash 一致),但 Chromium 根本不扫描 `Default/Extensions` |
| 2 | 手改 `Default/Preferences` 伪造注册条目(`location=4`) | 0 | **否** | 回写后 `disable_reasons=[16777216]`(MAC 校验失败被禁用) |
| 3 | CDP `Extensions.loadUnpacked`(浏览器级) | — | — | 146 上 `Extensions.getExtensions` 未实现、`loadUnpacked` 报 `Method not available`;加 `--enable-unsafe-extension-debugging` 仍不行 |
| 4 | 策略 `ExtensionSettings` + `force_installed` + 本地 CRX/update 服务 | 0 | **否** | 日志确认读到策略文件(`Found mandatory policy file`),但外部安装器**从不启动**,update 服务 0 次请求 |
| 5 | CRX 外部安装,只放文件不写对清单键名 | 0 | **否** | 见 3.3 的键名问题,`external_extensions.json` 用文档里的 `path` 会被判 `key external_extensions is not a valid id` |
| 6 | `chrome://extensions` UI「加载未打包的扩展程序」 | — | — | 按钮点击后弹**系统原生文件框**;无 `xdotool`、`Page.setInterceptFileChooserDialog` 拦不到(`fileChooserOpened` 事件 0 次) |
| 7 | **真实外部安装**:`external_extensions.json` + 内部键名 + 浏览器自打 CRX | **3/3** | **是** | 装成 `location=2`(EXTERNAL_DOWNLOAD),`Default/Extensions/<id>/1.0.0_0` 落盘,跨启动存活、`disable_reasons=[]`——**但 install 事件照样每次启动重发** |
第 7 条是唯一拿到「正式注册」的路线(详见 3.3),但它**依然没解决本问题**:
Chromium 每次启动都会把外部安装重新走一遍,于是每次都重新发 `install`。
补充:CRX 打包时**签名私钥必须与 `manifest.json` 的 `key` 同一把**,否则 CRX 头里的
`crx_id` 与 manifest 推导出的 ID 不一致,Chromium 直接拒装。
第一版测试就栽在这里(`scripts/mkcrx.py` 现在会强制回写 `key` 对齐)。
### 3.3 真实外部安装能装上,但仍然每次触发 install
这是本轮最关键的一条,补充记录成方法论:
**能装上的写法**(位置 `<USER_DATA>/External Extensions/external_extensions.json`):
```json
{ "<扩展ID>": { "external_crx": "/abs/path/probe.crx", "external_version": "1.0.0" } }
```
三个坑,每个都对应一次失败:
1. **键名不是文档里的那套。** 顶层必须是 `{"<id>": {...}}` 字典(不是
`{"external_extensions":[...]}` 列表);条目里认的是 `external_crx` / `external_update_url`
这类内部键,不是 `path`。用错时的报错分别是
`key external_extensions is not a valid id`、
`Exactly one of the following keys should be used: external_crx, external_update_url`、
`external_crx and external_version must be used together`。
2. **CRX 必须用浏览器自己打。** 手写的 CRX3 被拒:`程序包无效:"CRX_REQUIRED_PROOF_MISSING"`。
可用 `chrome --pack-extension=<dir> --pack-extension-key=<pem>` 生成。
3. **CRX 的 manifest 里不能留 `key` 字段**(或必须与签名公钥一致),否则 ID 对不上。
装上之后的实测:
```
location = 2 (EXTERNAL_DOWNLOAD)
path = <id>/1.0.0_0 # 版本目录带 _N 后缀 = 每次启动重新装一遍
disable_reasons = []
```
`external_update_url` 那条路在这个浏览器上是死的:`ExternalCrxInstaller` /
`ExternalDownloader` 符号在二进制里 **0 命中**(所有 build 都没有),
只有 `CrxUpdateService` 在,拉不到 CRX。本地 update 服务 0 次请求。
**而事件计数(3 次启动):**
```
launch 1: installs=1
launch 2: installs=2
launch 3: installs=3
```
**结论:在 CloakBrowser 146 上,不存在任何一种加载方式能让
`onInstalled("install")` 只触发一次。** 连「正式的外部安装」都做不到,
因为它每次启动同样会重跑安装流程。
### 3.3 重要副作用:扩展数据其实是持久的
首启写入 `marker`,后续不带任何标记重启:
```
B marker = {"boots":2,"marker":"written-in-first-launch"}
C marker = {"boots":3,"marker":"written-in-first-launch"}
```
即 **`chrome.storage.local` 跨启动保留,数据不丢**;重发的只是 `install` 事件本身。
另外 `Default/Extensions` 目录**根本不存在**——扩展代码只有共享目录那一份,
`USER_DATA` 里只留运行态数据(`Default/Local Extension Settings/<id>`)。
## 4. 结论
### 4.1 关于 bind mount 文档
文档第 12.4 节自己已经写明了这个前提:
> bind mount 本身不会改变 Chromium 的 Extension 生命周期。它只解决文件存储层共享问题。
> 要避免 `--load-extension` 每次启动产生的 install 行为,必须使用正常的 Extension 安装/注册方式。
实测把这句话补全了:**在 CloakBrowser 146 上,「正常的安装/注册方式」无法自动化。**
「只触发一次」要求扩展被写进 `Secure Preferences` 且 HMAC 正确,唯一能产生这个结果的
入口(外部安装器 / `loadUnpacked` / UI 加载)在此版本全部不可用。
所以:**照 bind mount 文档改,能达成「共享代码、不复制多份」,但达不成「install 只触发一次」。**
### 4.2 一个反直觉的结论:bind mount 想要的收益,现状已经拿到了
现状(`--load-extension` + 共享解压目录)其实**已经**满足文档第 1 节的目标
「让多个 Profile 共用同一份 Extension 文件」:
- 扩展代码:只有 `data/extensions/<extId>/` 一份,各 Profile 共享;
- `USER_DATA/Default/Extensions` 不存在,没有重复副本;
- 运行态数据:每 Profile 独立在 `Default/Local Extension Settings/<id>`。
也就是说,**改 bind mount 主要是把「共享」这件事从「共享目录 + 命令行指路」换成
「共享目录 + 挂载到 Profile 内」,收益是结构上的整洁,不是磁盘占用的大幅下降。**
(除非某些扩展会往自身目录写文件,那种情况下 bind mount 还能顺带省空间。)
### 4.3 想要「install 只触发一次」的可落地做法
> **2026-10-01 更新:本节"做不到"的结论已被静默安装方案推翻**,见 §7——
> 第 7 条路线的探针把 CRX 放在 Profile **外部**,这是「每次重装」的混淆变量;
> CRX 放进 Profile 内部(External Extensions 目录)后 install 天然只发一次。
> 下面的垫片方案保留,现仅用于 `EXTENSION_INSTALL_MODE=loadext` 回退链路。
**先说死结论:在 CloakBrowser 146 上做不到。** 3.2 的七条路线里,连拿到正式注册的
第 7 条(真实外部安装)都每次重发 `install`。事件本身无法阻止。
能落地的是**让这件事无害**,SerpMax 侧已实现的做法是「幂等安装垫片」:
- 实现:`node/src/core/browser/extension.install-shim.ts`
- 落点:`extension.sync.ts` 解压完插件后,把垫片拼到后台脚本(`sw.js` /
`service_worker.js` / `background.js`)最前面,把 `chrome.runtime.onInstalled.addListener`
包一层幂等闸门——首次放行,之后直接吞掉。
- 开关:`EXTENSION_INSTALL_SHIM`(默认开,置 `0` 关闭回到插件原始行为)。
- 标记键:`chrome.storage.local.__serpmax_init_done`,随 Profile 持久化,
因此「首次」是**每个 Profile 的首次**,跨浏览器重启有效。
实测(同一 Profile 连开 3 次,真实二进制 CloakBrowser 146):
| 模式 | install 事件 | 插件初始化真正执行次数 |
|---|---|---|
| `loadext`(现状) | 1 → 2 → 3 | 1 → 2 → 3 |
| `external`(真实外部安装) | 1 → 2 → 3 | 1 → 2 → 3 |
| `shim`(垫片) | 1 → 1 → 1 | 1 → 1 → **1** |
`shim` 那一行 `install 事件` 也是 1,是因为连探针自己的计数回调都被闸门挡住了——
这正是效果:**从插件视角看,install 只发生了第一次**。
> 垫片的取舍:它改的是第三方插件的后台脚本。SerpMax 的插件是用户上传的产物,
> 我们不掌握其源码,所以只能像这样在分发前注入。风险点是改坏了插件,
> 因此默认开但可一键关闭,且注入位置/幂等性都有单测覆盖
> (`node/tests/extension-install-shim.test.ts`)。
> 如果某个插件自己已经幂等,注入是无害的(闸门只会让它更早返回)。
## 5. 复现方式
```sh
# 三种模式对照,各连开 3 次统计 install 与初始化次数
cd node
bun scripts/probe-install-modes.ts loadext 3 # 现状:--load-extension
bun scripts/probe-install-modes.ts shim 3 # 加幂等垫片
# 真实外部安装模式需要先用浏览器自己打 CRX
EXE=/home/bigcode/data/.cloakbrowser/chromium-146.0.7680.177.5/chrome
cp -r tests/fixtures/probe-extension /tmp/pack-ext && rm -f /tmp/pack-ext/README.md
python3 - <<'PY'
import json; p='/tmp/pack-ext/manifest.json'
m=json.load(open(p)); m.pop('key', None); json.dump(m, open(p,'w'), indent=2, ensure_ascii=False)
PY
openssl genrsa -out /tmp/pack-key.pem 2048
$EXE --headless=new --no-sandbox --pack-extension=/tmp/pack-ext --pack-extension-key=/tmp/pack-key.pem
bun scripts/probe-install-modes.ts external 3 \
CRX_PATH=/tmp/pack-ext.crx EXT_ID=$(python3 -c "
import hashlib,subprocess
subprocess.run(['openssl','rsa','-in','/tmp/pack-key.pem','-pubout','-outform','DER','-out','/tmp/pub.der'],check=True)
h=hashlib.sha256(open('/tmp/pub.der','rb').read()).hexdigest()[:32]
print(''.join(chr(ord('a')+int(c,16)) for c in h))")
# 需要自己写 CRX 打包时(会强制回写 manifest.key 与签名私钥对齐)
python3 scripts/mkcrx.py tests/fixtures/probe-extension /tmp/probe.crx /tmp/probe.pem
```
## 6. 涉及的仓库文件
- `node/tests/fixtures/probe-extension/` — 探针扩展(`manifest.json` / `sw.js` / `README.md`)
- `node/scripts/probe-install-modes.ts` — 三模式(loadext / external / shim)多启动计数脚本
- `node/scripts/probe-silent-install.ts` — **静默安装端到端探针(生产模块链,见 §7)**
- `node/scripts/mkcrx.py` — CRX3 打包(强制 manifest.key 与签名私钥一致)
- `node/src/core/browser/extension.install-shim.ts` — 幂等安装垫片(注入 + 幂等闸门,现仅 loadext 回退用)
- `node/src/core/browser/extension.silent.ts` — **静默安装(稳定私钥/CRX 打包/External Extensions 写入)**
- `node/src/core/browser/extension.sync.ts` — 同步分流:silent(默认)/ loadext 回退
- `node/tests/extension-install-shim.test.ts` — 垫片单测(定位/幂等/语法/异常兜底)
- `node/tests/extension.silent.test.ts` — 静默安装单测(buildVersion/extIdFromPem/installSilently)
> 脚本里刻意**不用 Playwright** 启动:Playwright 默认带的是**旧 headless**
> (`--headless`),旧 headless 根本不加载扩展,会读出「扩展没装」的假象。
> 必须 `--headless=new`,或直接在有显示的环境里有头启动。
## 7. 静默安装定版(2026-10-01,推翻 §4.3 的"做不到")
Policy 变更(用户定版):**插件装载从「共享目录 + `--load-extension` 命令行装载」
改为「每个 Profile 静默安装」,不再坚持插件文件共享。**
### 7.1 为什么旧探针得出"外部安装每次重发 install"的假结论
§3.2 第 7 条(真实外部安装)的探针把 CRX 放在 **Profile 外部**(如 `/tmp/probe.crx`),
`external_extensions.json` 指过去。实测版本目录出现 `_N` 后缀、install 每次重发。
2026-10-01 复测发现混淆变量:**CRX 放进 Profile 内部**(`<USER_DATA>/External Extensions/`
目录)后,install 天然只在首次触发(同 Profile 连开 3 次 installs = 1→1→1,
boots = 1→2→3,SW 每次正常唤醒)。此 build 的外部安装不落
`Default/Extensions/<id>/<version>`(始终为空),扩展直接从 CRX 路径装载。
### 7.2 两个杀手旗标(Playwright 默认参数集)
裸 spawn 下能装 ≠ 生产 launchPersistentContext 下能装。逐旗标二分(每轮全新 Profile)
定位出两个必须经 `launchOptions.ignoreDefaultArgs` 剔除的 Playwright 默认旗标:
| 旗标 | 效果 |
|---|---|
| `--disable-extensions` | 扩展系统整体关闭(§3.2 已知) |
| `--disable-default-apps` | **关闭 ExternalProvider,外部 CRX 静默安装直接不执行(新发现)** |
注:仅有 `--disable-extensions-except`(不带 `--load-extension`)**救不回来**——
实测装不上。cloakbrowser 的 `ignoreDefaultArgs` 字段被 `launchOptions` 展开
顺序覆盖(`dist/playwright.js` 装配顺序),所以生产代码经
`launchOptions: { ignoreDefaultArgs: ["--enable-automation", "--enable-unsafe-swiftshader", "--disable-extensions", "--disable-default-apps"] }`
一并剔除(cloak 原有两项必须保留,否则 `--enable-automation` 回来破坏隐身)。
### 7.3 稳定扩展 ID 与更新语义
- 扩展 ID = 签名公钥推导(SPKI DER → sha256 前 16 字节 → a..p)。私钥按 extId
固定存 `{extensionDir}/{extId}.key.pem`(600)→ 重打包 ID 不变。
**私钥不能放扩展目录内**——`--pack-extension` 遇目录内 .pem 直接拒绝
(「此扩展程序包含密钥文件」)。manifest 必须删 `key` 字段(实测保留会 ID 漂移)。
- CRX 只能浏览器自打(§3.2 第 7 条),命令:
`$EXE --headless=new --no-sandbox --pack-extension=<dir> --pack-extension-key=<pem>`
- **更新语义**(每轮全新 Profile 实测):
- 同 version 覆盖 CRX → **不会更新**,一直跑旧代码;
- manifest 版本追加第 4 段(如 `1.0.0` → `1.0.0.2`)且 `external_version` 同步 →
下次启动 Chromium 重装,install 以 `reason="update"` 恰好重发一次
(`previousVersion` 正确),新代码从再下一次 SW 启动生效。
- 因此 staging 状态文件记录 `{ sha256, origVersion, build }`:zip sha256 每变化
一次 build+1,打包版本 = `buildVersion(origVersion, build)`。
### 7.4 生产链路落点(node v0.8.0)
```
extension.sync.ts ensureExtensionsReady → { dirs(loadext), silent(silent) }
staging:下载/解压/sha 增量;sha 变化时删外层旧 CRX + build+1
extension.silent.ts 稳定私钥 → manifest 变换(删 key/版本对齐)→ 浏览器自打 CRX
→ installSilently(复制 CRX 进 Profile + external_extensions.json
整文件重写 + 无主 CRX 清退)
browser.manager.ts silent:installSilently 写 Profile + ignoreDefaultArgs 剔除两旗标
(不再传 extensionPaths);无插件仍 --disable-extensions
```
配置:`EXTENSION_INSTALL_MODE`(默认 `silent`,`loadext` 回退旧链路,垫片只在
loadext 生效)、`EXTENSION_INSTALL_SHIM`(不变)。
复现:
```sh
cd node
xvfb-run -a bun scripts/probe-silent-install.ts 3 # 生产模块链端到端,预期 installs 1→1→1
bun test tests/extension.silent.test.ts tests/extension.sync.test.ts
```
实测记录(2026-10-01,真实二进制 cloak 146):
首装 installs=1;三连启 1→1→1;更新启动后 installs=["install","update"]、
codeTags 出现新版本标记;同 Profile 数据跨更新保留。
+86
View File
@@ -0,0 +1,86 @@
2607:f8b0:4006:804::200e
2607:f8b0:4006:80f::200e
2607:f8b0:4002:c00::8a
2607:f8b0:4002:c00::66
2607:f8b0:4002:c00::71
2607:f8b0:4002:c00::8b
2607:f8b0:4006:800::200e
2607:f8b0:4006:811::200e
2a00:1450:4025:401::65
2a00:1450:4025:401::71
2a00:1450:4025:401::64
2a00:1450:4025:401::66
2a00:1450:4019:812::200e
2607:f8b0:400a:809::200e
2a00:1450:4007:812::200e
2a00:1450:4003:809::200e
2a00:1450:400a:1001::65
2a00:1450:400a:1001::66
2a00:1450:400a:1001::8a
2a00:1450:400a:1001::71
2a00:1450:4001:c17::71
2a00:1450:4001:c17::8b
2a00:1450:4001:c17::64
2a00:1450:4001:c17::8a
2607:f8b0:4004:c25::65
2607:f8b0:4004:c25::71
2607:f8b0:4004:c25::8a
2607:f8b0:4004:c25::64
2a00:1450:400e:806::200e
2a00:1450:4001:c15::66
2a00:1450:4001:c15::8b
2a00:1450:4001:c15::65
2a00:1450:4001:c15::71
2607:f8b0:4012:828::200e
2607:f8b0:4005:80b::200e
2404:6800:4001:c01::8b
2404:6800:4001:c01::66
2404:6800:4001:c01::65
2404:6800:4001:c01::71
2607:f8b0:4006:81f::200e
2404:6800:4006:803::200e
2001:4860:4802:32::78
2404:6800:400a:1000::8b
2404:6800:400a:1000::65
2404:6800:400a:1000::66
2404:6800:400a:1000::71
2a00:1450:4017:815::200e
2404:6800:4007:83c::200e
2a00:1450:4019:803::200e
2a00:1450:4003:818::200e
2607:f8b0:4006:816::200e
2404:6800:4007:83a::200e
2607:f8b0:4004:c2f::64
2607:f8b0:4004:c2f::65
2607:f8b0:4004:c2f::66
2607:f8b0:4004:c2f::71
2607:f8b0:4004:c1b::64
2607:f8b0:4004:c1b::65
2607:f8b0:4004:c1b::66
2607:f8b0:4004:c1b::71
2607:f8b0:400a:80d::200e
2607:f8b0:4007:80b::200e
2800:3f0:4001:846::200e
2a00:1450:4007:815::200e
2a00:1450:4017:819::200e
2a00:1450:4001:c1f::64
2a00:1450:4001:c1f::65
2a00:1450:4001:c1f::66
2a00:1450:4001:c1f::8b
2c0f:fb50:4002:813::200e
2a00:1450:4017:807::200e
2a00:1450:4010:c1c::64
2a00:1450:4010:c1c::65
2a00:1450:4010:c1c::71
2a00:1450:4010:c1c::8b
2a00:1450:4019:815::200e
2404:6800:4000:1015::64
2404:6800:4000:1015::71
2404:6800:4000:1015::8a
2404:6800:4000:1015::8b
2404:6800:4008:c13::64
2404:6800:4008:c13::65
2404:6800:4008:c13::66
2404:6800:4008:c13::8a
2404:6800:4005:81a::200e
2404:6800:4006:800::200e
+113
View File
@@ -0,0 +1,113 @@
# SerpMax(ProfileMax)审计准备:功能清单与技术栈
> 生成日期:2026-10-28。版本以各服务 package.json 为准:admin v1.74.2、node v0.43.0。
> 本文所有"审计重点"均已逐条核实源码(含 file:line),非推断。
## 一、项目定位与总体架构
SerpMax 是一套**反检测指纹浏览器农场管理平台**:集中管理大量带浏览器指纹的 Profile(指纹、代理、Cookie、插件),在多个远程节点上批量启动 Chromium 并自动化,配套 SERP 采集、Cloudflare/reCAPTCHA 挑战处理、VNC/WebDesk 人工接管,并通过 MCP 接口暴露给 AI Agent。
- **admin**(`admin/`,profilemax-admin v1.74.2):管理后台单进程 Bun 服务(端口 5300),持有 PostgreSQL;React 前端构建产物由 nginx 直出。浏览器操作类 API 大多为**中继**:按 `profile.node → nodes.base_url` 转发到节点并附节点密钥(见 `docs/API.md` 头部)。
- **node**(`node/`,profilemax-node v0.43.0):运行节点(端口 3100),无 UI、无业务数据库(仅只读 Chrome History 的本地 SQLite);Docker privileged 部署,管理浏览器进程、Xvfb/x11vnc、WebDesk、impersonate MITM 代理、CRIU 快照。
- **stress/**:并发常驻浏览器压测套件(造号、发射器、/proc PSS 采样、报告)。
## 二、技术栈
| 层 | 技术 |
|---|---|
| 运行时 | Bun(oven/bun 1.4)+ TypeScript,裸 `Bun.serve()` routes,无 Express/Hono 等框架 |
| 浏览器内核 | **双内核**:veil-chromium(自研,ungoogled-chromium 153 + C++ 指纹补丁,指纹由启动旗标驱动)与 cloakbrowser npm 包(包装 Chromium + Playwright API) |
| 自动化 | playwright-core(CDP 驱动) |
| admin 数据库 | PostgreSQL(`Bun.sql`),`pgarray.ts` 处理 text[] |
| admin 前端 | React 19 + antd 6 + Tailwind 4(`bun-plugin-tailwind` + `Bun.build`,无 vite/webpack),lucide-react、flag-icons、**@novnc/novnc**(RFB over WebSocket) |
| 查看器 | noVNC + 自研 WebDesk(`vendor/webdesk/webdesk-linux-static` 静态二进制:WebP 帧流 + 私有二进制协议 0x10/0x11/0x12/0x13-14/0x16,XTEST 键鼠注入) |
| 网络伪装 | 自研 impersonate-proxy(Go,本地 MITM:uTLS/JA3/HTTP2/头序伪装)+ socks5 链;`socks-proxy-agent`;mmdb-lib(GeoIP) |
| 对象存储 | 自研 S3 SigV4 客户端(`admin/src/core/s3.ts`,path-style、Multipart 64MB 分片,零第三方依赖),用于备份上传 |
| 身份 | 本地账密(Bun.password/bcrypt)+ ZITADEL OIDC(Authorization Code + PKCE,公共客户端) |
| 打码 | recaptcha-answer 外部识别服务(4x4 宫格远端 AI,其余 ONNX 本地) |
| 快照 | CRIU checkpoint/restore 整棵 Chromium 进程树(仅 headless,D-Bus 隔离) |
| 部署 | Docker(privileged,代码挂卷 + `/system/update` 自更新)、systemd 免 Docker 安装包、pg_basebackup/pg_dump 备份 |
## 三、功能清单
### admin 侧(管理后台)
**认证与多用户**:登录/登出/当前用户、改密、API Token 签发与轮换;ZITADEL OIDC 绑定/解绑/JIT 建号;用户 CRUD 与菜单权限分配(仅内置 id=1 为 admin,`session.ts` 强制)。
**Profile 管理**:CRUD、创建时一次生成指纹快照并固化(数字种子/UA/屏幕/时区语言/WebGL/canvas·audio 噪声种子/字体)、批量、复制(duplicate)、转移、分享(`profile_shares`:use=查看运行 / edit=编辑删除分享)、启停、浏览器状态、磁盘软清/硬重置。
**Cookie 与存储**:Cookie Jar 读写/批量删除/导入导出;浏览器运行时 Cookie 与 Jar 双写同步;Profile 存储(IndexedDB/localStorage)与插件自有数据 zip 导出导入。
**代理**:CRUD、批量导入、标签/facets、出口 IP 测试、启停;socks5/socks5h/http/https。
**DNS(替代 hosts)**:host→IP 条目 CRUD、内容检测规则(正则/子串,如 google-serp)、经指定代理探测(google 系取首页匹配+延迟)、通过的条目打标签组并随代理下发到绑定 Profile;节点经 `/api/node/dns/entries.json` 全量同步。
**插件库**:上传 zip、Chrome Web Store 详情页导入、sha256 增量下发节点、静默安装(删 manifest.key+版本加 build 号→浏览器自打 CRX→External Extensions)。
**脚本系统**:两种 kind——`code` 存 JS/TS 源码、`schema` 存 JSON 步骤编排;标签;直接执行(run 需显式 profileRefs)。
**任务系统**:任务=脚本+Profile 标签圈定+并发上限+逐目标错峰+周期+jitter;调度器每 60s tick,SQL 原子认领到期任务;运行记录、取消;`zombieReaper` 收割重启遗留的 RUNNING 僵尸。
**SERP 采集**:Google 搜索,json/html/raw 三形态输出(兼容 Rust serp-api 形状)、温页池(warm→直接在温页上搜索,降低触发风控)、缓存、统计、健康检查。
**挑战处理**:每 Profile 可开关的 CF Turnstile 自动过盾与 reCAPTCHA 自动打码;reCAPTCHA 手动打码会话(前端 CaptchaSolveModal 经中继点格子)。
**浏览器中继**(`routes/browser-relay.ts`,全部转发节点):SSE 事件流(state/动态 FPS 截图)、批量动作、execute、启停/窗口、cf-solver、recaptcha-solver、VNC/WebDesk WS 中继(WS 握手做 Profile 访问控制)、captcha 图块接口、httpcache 统计。
**节点管理**:注册/心跳/自更新(`/api/node-package`、`/node/version`,含 `/api` 别名规避 CF 520)/安装脚本下发/硬件信息。
**MCP Server**(`admin/src/api/mcp.ts`,1033 行):`POST /mcp`,JSON-RPC 2.0 无状态 Streamable HTTP,协议版本 2025-03-26;**54 个工具**(Profile 全生命周期、fetch_url/fetch_by_condition、execute_js、代理/插件/Cookie/节点查询、脚本与任务 CRUD+运行+取消等);认证=Bearer 用户 API Token;工具直接调用内部处理器,自动继承多用户访问控制。
**运维**:事件日志查询/清理、Dashboard 聚合统计、S3 配置(仅 root)与备份(pg_basebackup 全量/增量 + pg_dump 逻辑 + S3 上传,`backup_runs` 表)、settings KV、Prometheus `/metrics`(节点侧)、WebDesk 免登录起始页(HMAC 节点密钥签名)。
### node 侧(运行节点)
- **Profile 运行**:启动/停止/重启、CDP 端点、crash 处理与恢复、空闲回收(reaper)、磁盘配额;快照开关(CRIU dump/restore 保留页面现场)。
- **Lease**:Acquire/Heartbeat/Release/超时扫描,保证一个 Profile 同一时间一个消费者;超时自动停浏览器回 READY。
- **Fetch 体系**:Profile 上下文 HTTP fetch(代理+UA+Cookie 三来源合并);`impersonate.fetch` 走本地 impersonate-proxy MITM(uTLS/JA3/HTTP2)再经 socks5 出站,执行体 go-fetch 二进制、curl 回落;每 Profile 一个 impersonate-proxy 进程(独立端口+YAML+共享 CA)。
- **WebRTC 防泄露**:`--force-webrtc-ip-handling-policy=disable_non_proxied_udp` + init script 改写 SDP/ICE 候选为代理出口 IP。
- **浏览器控制**:`browser-control.ts` 批量动作 + SSE(state/screenshot 动态 FPS)。
- **SERP 浏览池**:pool 快照/warm/search/unwarm。
- **VNC/WebDesk**:每浏览器独立 Xvfb + x11vnc(display≥100、rfbport≥6100);WebDesk 挂同一 Xvfb 提供私有 WS 协议,agent 只听 127.0.0.1+随机 token,node/admin 透明字节管道。
- **一次性直连票据**(`direct.ts`):VNC/直连 5 分钟有效。
- **节点系统 API**:`/health` `/ready` `/metrics` `/events`、目录覆盖、自更新、Schema 编排执行器(lease→起浏览器→逐步执行→释放,错误策略 stop/continue/retry)。
## 四、数据层(admin,PostgreSQL,24 张表)
`migrate.ts`(1075 行,幂等 `CREATE TABLE IF NOT EXISTS` + `ALTER TABLE ADD COLUMN IF NOT EXISTS`,启动即跑,无版本表):
`proxies`、`profiles`、`profile_statistics`、`cookies`、`profile_storage`、`logs`、`captcha_events`、`settings`(KV)、`admin_user`、`sessions`、`users`、`scripts`、`tasks`、`profile_task_state`、`task_runs`、`profile_shares`、`dns_rules`、`dns_entries`、`extensions`、`nodes`、`oidc_identities`、`serp_requests`、`goto_cache`、`backup_runs`。
repo 层 `admin/src/repos/*` 每表一文件。
## 五、认证与授权模型(已核实)
- **会话双通道**(`admin/src/api/session.ts`):①Cookie `smax_session`(HttpOnly + **SameSite=Lax** + 可选 Secure,token=两个 uuidv7 拼接,存 sessions 表,TTL 可配);②`Bearer <users.api_token>`(`smax_` 前缀)。
- **权限阶梯**:`requireUser` → `requireAdmin`(role=admin)→ `requireRoot`(仅 id=1);业务菜单级权限 `requirePerm(req, "tasks" 等)`;Profile 级 `loadProfileFor`(归属者/被分享者/admin),`access.ts` 统一 404/403。
- **密码**:Bun.password(bcrypt);**默认管理员密码**来自 `config.defaultAdminPassword`。
- **OIDC**:ZITADEL Auth Code + PKCE(公共客户端);state 一次性消费 10 分钟(注释明示作 CSRF 防护);身份主键 (issuer, subject),绝不以 email 为键;配置存 settings KV。
- **节点认证**(`node/src/api/middleware/node-auth.ts`):配置 `NODE_KEY` 时要求 `x-node-key` 头或 Bearer;**未配置则全放行**(注释:本机/内网模式)。
- **MCP**:仅 Bearer 用户 API Token,未认证 401。
## 六、审计重点关注项(逐条核实)
1. **脚本执行无沙箱(最高优先级)** —— `node/src/core/schema/schema.actions.ts:246-257`:用户 JS/TS 先包进 `async function __task__(ctx){...}`,`Bun.Transpiler` 剥 TS 类型后 `new Function(...)` 在**节点服务进程内**执行。`new Function` 作用域是全局,Bun/node API(fs、网络、子进程)全部可达 —— 等于服务器任意代码执行。`ctx` 本身也提供完整 Playwright API 与 task_state 读写。多用户部署下,任何拥有 tasks 菜单权限的普通用户可创建/运行脚本(`handleScriptCreate/Run` 仅 `requirePerm(req,"tasks")`,见 `admin/src/api/routes/tasks.ts:148/779`)→ 等效 RCE;单管理员私有部署则属设计使然,但仍建议在报告中明确标注。
2. **仓库内硬编码密钥** —— `node/deploy/docker-compose.yml:10`:`NODE_KEY=18036c...` 为未注释的真实样例值,随仓库分发。若该 key 曾用于生产,需轮换。
3. **节点认证强度** —— `node-auth.ts` 为普通字符串比较(非常量时间);`timingSafeEqual` 仅在 `admin/src/api/routes/webdesk.ts:181`(HMAC 比较)使用。未配置 NODE_KEY 时节点 API 完全开放(依赖网络隔离)。
4. **登录无防爆破** —— 全库未发现 rate limit / 登录失败锁定;无独立 CSRF token,依赖会话 Cookie 的 SameSite=Lax 兜底(session.ts:144-151,已核实)。
5. **CORS 通配** —— `admin/src/api/routes/webdesk.ts:25`:`access-control-allow-origin: *`,仅作用于两条 WebDesk 免登录路由(已核实仅此文件),该组路由用 HMAC 节点密钥保护。
6. **查询参数携带凭证** —— VNC/WebDesk WS 鉴权使用 `?token=` / `?ticket=`(SDK 限制),凭证会进代理/访问日志;票据为 5 分钟一次性(direct.ts)。
7. **节点本身高权限** —— Docker privileged 容器内跑 Xvfb/x11vnc/CRIU dump-restore/impersonate MITM(共享本地 CA 私钥);节点被攻破=宿主机被攻破,节点与管理面间是 NODE_KEY 单因素。
8. **execute_js(MCP 工具)** —— 在 Profile 页面上下文执行任意 JS;单看是低危页面脚本,但结合第 1 条(脚本系统)即构成完整 RCE 面;MCP Token 与用户 API Token 同一凭证,泄露即获得该用户全部 54 个工具能力。
9. **多用户边界** —— Profile 权限模型完善(归属/分享两级+菜单权限),但 admin 强制仅 id=1 为 admin(`session.ts`);历史 admin 角色降级,需在审计中确认用户升级路径的一致性。
## 七、仓库结构与文档地图
- `docs/`:`API.md`(全栈 API:admin 5310 行/node 3100 行+中继架构)、CRIU 快照方案、插件 bind-mount 存档、Google IPv6 列表。
- `admin/docs/`(AI 可读,经 /docs 免登录直出):overview、rest-api、mcp、schema、scripts、tasks、serp、VNC、API、开发执行文档、验收截图。
- `node/docs/`:技术开发规范、Browser-Control 协议、webdesk-viewer 协议、动态 FPS 截图模块、veil 内核迁移问题清单。
- `admin/scripts/`:前端构建、CDP 可视化验收、本地 SOCKS5 开发代理、**keepalive-flow.ts(生产级保活脚本 v3)**、插件恢复。
- `node/scripts/`:验收(20 步含 crash/lease/schema 超时)、CRIU PoC/基准、各探针、mkcrx.py、systemd 打包。
- `node/deploy/`:Dockerfile.node、docker-compose.yml(privileged)。
- `stress/`:造号、压测发射器、PSS 采样、报告(OVH-FR 8C/31G 实测)、清理工具。
- 根目录 `tests-ka-flow.ts`:保活脚本状态机测试(mock ctx 复刻节点 script.run 管线)。