# NexusGate 面板差分 E2E(Node 面板 vs Go 面板) 同一种子 DB、同一套操作剧本,分别跑在 Node 面板(`server.js` + `lib/`)与 Go 面板(`panel-go/` 的移植产物)上,逐步比对响应语义是否一致。脚手架只用 Node ≥ 18 标准库(`fetch` / `fs` / `child_process` / `net`),文件全部在本 目录:`lib.mjs`(进程管理 + HTTP 客户端 + 学习式归一化器 + 任务 drain)、 `seed.mjs`(黄金种子生成)、`run.mjs`(差分主流程)。 ## 快速开始 ```bash cd panel-go/difftest # 1) 生成黄金种子(真实启动一次 Node 面板,走完整业务流) node seed.mjs # 2) 自检:Node vs Node 同剧本跑两遍,验证脚手架本身的归一化可靠 node run.mjs --selftest # 期望末尾 SELFTEST PASS # 3) 正式模式:Node vs Go node run.mjs --bin /path/to/nexusgate-panel-linux-amd64 # 或 GO_PANEL_BIN=/path/to/nexusgate-panel-linux-amd64 node run.mjs # 二进制默认路径:panel-go/nexusgate-panel-linux-amd64(协调者构建) node run.mjs # 有二进制走正式模式;没有则自检 + FORMAL SKIP ``` 分辨率顺序:`--bin` > 环境变量 `GO_PANEL_BIN` > 默认路径。二进制缺失时只跑 Node 自对自 sanity 并明确打印 `FORMAL SKIP (Go panel binary not found at ...)`, 退出码仍由自检结果决定(自检全过为 0)。`seed.json` 不存在时 `run.mjs` 会先 自动调 `seed.mjs` 生成。其他参数:`--seed `(同目录须有 `seed-log.json` / `seed-expect.json`)、`--keep`(保留临时目录便于排障)。 Go 面板需遵守与 Node 面板相同的启动契约(脚手架只通过环境变量配置): `NG_HOST` / `NG_PORT` / `NG_DATA_FILE` / `NG_ADMIN_PASSWORD` / `NG_COOKIE_SECURE=false`,并提供 `GET /healthz`。两侧都用随机高位端口、 `fs.mkdtemp` 临时数据文件启动,结束可靠杀进程;仓库 `data/` 目录永不触碰。 ## 种子(seed.mjs) 以 `NG_ADMIN_PASSWORD=diff-admin-pass` 启动 Node 面板后,仅通过公开 HTTP API 走完:登录 → 建入口服务器(`entry.example.com` / `v6.example.com` / `tls.example.com`,端口范围 20000–50000)与出口服务器(`exit.example.com`)→ 两台各走 enrollment-token + 模拟 agent enroll → 客户 Alice(无限制)与 Bob (流量上限 1073741824、ipLimit 5、deviceLimit 2)→ 节点 N1(forward、 `vless-reality-vision`、出口 `shadowsocks-2022-aes256`、 realityServerName `www.amd.com`)与 N2(direct、`shadowsocks-2022-aes128`)→ N1 分配 Alice+Bob、N2 分配 Alice → deploy 两节点 → agent poll 取任务并以 artifacts 回 complete(N1 的 `realityPublicKey` 取 poll 到的 `payload.keyMaterial` 中 `reality-` 对应条目的 publicKey,模拟真实 agent 的密钥落地回传;其余 artifacts 为 `{}`)→ 断言两节点 active → 拉取 Alice 全部订阅格式存档为期望值 → 上报 usage(user 样本 Alice 上行 1000 / 下行 2000,inbound 样本 N1 上行 4000 / 下行 5000,epoch `diff-epoch-1`)与 observations(Alice 一个 IP `203.0.113.10`)→ 导出此刻 DB。 产物(均在本目录): | 文件 | 内容 | |---|---| | `seed.json` | 导出的面板 DB(黄金种子;含 keyStore 中 N1 的 Reality 密钥对) | | `seed-expect.json` | Alice 在 usage 上报**之前**的订阅响应(raw/base64/clash/clash-smart/singbox/surge/auto×2UA);base64 类 body 以解码后的比较形式存储 | | `seed-log.json` | 全过程请求/响应日志;`meta.agentKeys` 保存种子里两台 agent 的原始 key(DB 只存哈希,阶段二驱动种子 agent 只能从这里恢复) | ## 阶段二剧本(run.mjs) 两边各复制一份 `seed.json` 起面板(不同端口;种子已有用户,故 bootstrap 不触发,直接用 `diff-admin-pass` 登录),逐步执行并逐步归一化比对 (PASS/FAIL 不中断,跑完所有步;FAIL 给出首个差异的 JSON 路径与两边值摘要): 1. 登录 / session → 种子 agent 心跳(不带 `resources`,不触发对账)→ overview → servers/customers/nodes/jobs 列表; 2. 新建服务器 S3 + enrollment + enroll → 新建客户 Carol → 新建节点 N3 (direct、`vless-reality`)→ 分配 Carol → deploy → poll/complete (公钥同种子法)→ 节点列表断言 active; 3. Carol 订阅全格式两边逐字比(经归一化)→ Alice 订阅全格式两边比对, 且每边 body 与 `seed-expect.json` 逐字比(`rawEqSeed`;userinfo 头不比 种子值,因为种子期望捕获于 usage 上报前); 4. usage 上报(Alice +500/+700,epoch `diff-phase2-epoch-1`)→ customers 列表比对 usedBytes(应为 4200); 5. S3 agent heartbeat 带 `resources: []` 触发对账 → poll 应拿到被重排的 apply(断言两边都有且 payload 语义等)→ complete 恢复 active; 6. 删除 N3(remove → poll delete → complete → 回到 draft)→ S3 forget 流程:在线 forget 应 409(有关联时与删关联后各一次)、改名确认应 400、 最后经 DELETE 服务器完成清理; 7. backup 接口内容语义比对(整份 DB 快照经归一化深比); 8. 错误用例组:未登录 401、CSRF 缺失 403、登录失败文案、不存在节点 404 文案、订阅坏 token 404、订阅 info 页比对(归一化日期与 token 后)。 随后是 **DB 互通**:停服 → 先比对两边 DB 文件的关键计数(keyStore 条数、 jobs 数、Alice usedBytes,应为 1 / 两边相等 / 4200)→ 交换 DB 文件 → 各自 重启对方实现的 DB → 再跑登录、列表接口与 Alice 订阅拉取(仍应与种子期望 逐字一致)→ 各自改一次客户 notes 确认能写。 汇总输出逐步 PASS/FAIL/SKIP 与 `SUMMARY total/pass/fail/skip`,退出码 0(全过或仅 SKIP)/ 1(有 FAIL)。 ## 归一化规则(lib.mjs 的 Normalizer,「学习表」机制) 每侧持有一个 Normalizer;每个步骤的原始响应先 `learn()` 再 `normalize()` 后比对。对象按**排序后的键**遍历、数组保持语义顺序,因此占位编号不依赖 任一实现的序列化键序。占位按类别内出现顺序编号,两侧同序喂入即同号: | 类别 | 规则 | 占位示例 | |---|---|---| | 带前缀 ID | `usr/srv/agt/enr/cus/nod/ncl/res/job/evt/obs/acc_` 全文正则(包括嵌在字符串、对象键如 `reality-`、`srv:` 中的) | `#2` | | 节点 tag 片段 | sing-box tag 由 node id 去符号后取末 18 位派生(`ng-<片段>-in`),学到 node id 时同步登记片段 → 归属 id 的占位 | `ng-#3-in` | | 裸 UUID | 凭据 `clientId` 等无前缀 UUID | `#4` | | ISO 时间戳 | `…T…Z/±hh:mm` 全部 | `` | | 本地化日期 | info 页 `2026/10/20 16:05:03` 形式 | `` | | 字段学习型秘密 | 按字段名学习:`subscriptionToken`/`agentKey`/`token`(enrollment)/`csrf`/`passwordHash`/`tokenHash`/`keyHash`(含 `scrypt:…` 全文模式)/`keyFingerprint`/`clientKey`/`privateKey`/`publicKey`/`realityPublicKey`/`relayPassword`/`ssServerKey`/`wsPath`/`password`/`shortId`/`short_id`、ws 传输 `path`(`/` 形式);替换时含其 16 字符前缀形式(info 页展示) | `#1`、`#1`、`#3` | | 随机端口 | 仅从字段 `port`/`exitPort`/`listen_port`/`server_port` 学习数值,JSON 中同名字段与文本中独立出现的该数字一并替换;`portRangeStart` 等确定性数字不学不换 | `#2` | | ss:// userinfo | SS 链接的 base64 userinfo 整体按出现顺序编号(其内容是 ssServerKey 与 relayPassword 的确定性函数) | `ss://#1@…` | | 面板自身地址 | info 页里的 `http://127.0.0.1:<本侧端口>` | `` | 文本体(订阅)同样先归一化后逐字比;base64 类格式先解码再比(见 `comparisonBody`)。深比对(`firstDiff`)对象键不敏感、数组顺序敏感。 ## 已知易变字段清单 以下字段天生随机/易变,全部已被上表规则覆盖,比对时**不要**绕过归一化 直接比原文:各类带前缀 ID 与裸 UUID、sing-box tag 片段、所有 ISO 时间戳(`createdAt`/`updatedAt`/`lastSeenAt`/`expiresAt`/`serverTime`/ `leaseUntil`/`completedAt`/activity `at`、info 页本地化日期、backup 文件 名日期)、`subscriptionToken`(含 info 页 16 字符前缀)、`agentKey`、 enrollment token 值、session `csrf`、`passwordHash`/`tokenHash`/`keyHash` 的 scrypt 盐与哈希、`keyFingerprint`、订阅 `clientKey`(HMAC)、Reality 私钥/公钥(keyStore、keyMaterial、artifacts、订阅 `pbk=`)、 `ssServerKey`、每客户 `relayPassword`、出口凭据密码、`wsPath`、 `shortId`、节点随机分配的 `port`/`exitPort`、SS 链接 userinfo。 ## 已知限制与注意事项 - **forget 成功路径在本脚手架内不可达**:服务器 `online` 由 lastSeenAt 在 180s 内推导,造不出“离线”状态而不真等 3 分钟;剧本覆盖了在线 409 与名称确认 400 两个错误分支,成功 forget 待 Go 面板就绪后可用假时钟或 直接篡改 DB 中 lastSeenAt 的变体补测。 - **服务器在线状态/ overview.online 是时间敏感量**:两侧步骤背靠背执行 (亚秒级间隔),且剧本先用种子 agent 心跳把 lastSeenAt 刷新到确定性 online;若单步耗时异常拉长,仍有极小概率在 120s/180s 边界两侧不一致。 - **端口占位按数值全局映射**:不同服务器上的节点理论可分配到同一数值 端口(概率 ~1e-4/次),此时占位会合并,可能造成个别步假阳性 FAIL; 重跑种子或重跑即可区分。 - 未学到的秘密(如阶段二里 Alice 的 relayPassword 未再经 poll 出现)靠 “两侧同源于同一 seed 副本、原文即相同”成立;若某未学秘密原文里恰含 已学端口数字的独立片段,存在可忽略概率的误替换。 - 登录失败错误用例每侧只打 1 次,避免触发 6 次锁定(15 分钟)污染后续步。