Files
aaaamoooo 3951134940
CI / test (push) Canceled after 0s
CI / anytls-smoke (push) Canceled after 0s
feat: Go port of the panel in panel-go/ — drop-in replacement for server.js
Pure-stdlib Go implementation of the whole control plane: same
nexusgate.json schema (unknown fields preserved verbatim, explicit
null/false tri-states modeled), same HTTP API and error strings,
embedded frontend (NG_PUBLIC_DIR override), self-implemented scrypt
auth, X25519 Reality keys, panel-hosted keyStore and heartbeat
reconciliation included. Version stays 0.6.8.

Verified: Go tests 33/33 (scrypt + Reality vectors generated from
the Node implementation, subscription goldens byte-identical),
Node suite 52/52 untouched, differential E2E against the Node
panel 50/50 including two-way DB swap. See panel-go/PARITY.md
and panel-go/INSTALL-GO.md.
2026-10-20 08:21:54 +00:00

147 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.json 路径>`(同目录须有
`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-<nodeId>` 对应条目的 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_<uuid>` 全文正则(包括嵌在字符串、对象键如 `reality-<nod id>`、`srv:<id>` 中的) | `<cus>#2` |
| 节点 tag 片段 | sing-box tag 由 node id 去符号后取末 18 位派生(`ng-<片段>-in`),学到 node id 时同步登记片段 → 归属 id 的占位 | `ng-<nod>#3-in` |
| 裸 UUID | 凭据 `clientId` 等无前缀 UUID | `<uuid>#4` |
| ISO 时间戳 | `…T…Z/±hh:mm` 全部 | `<ts>` |
| 本地化日期 | info 页 `2026/10/20 16:05:03` 形式 | `<date>` |
| 字段学习型秘密 | 按字段名学习:`subscriptionToken`/`agentKey`/`token`(enrollment)/`csrf`/`passwordHash`/`tokenHash`/`keyHash`(含 `scrypt:…` 全文模式)/`keyFingerprint`/`clientKey`/`privateKey`/`publicKey`/`realityPublicKey`/`relayPassword`/`ssServerKey`/`wsPath`/`password`/`shortId`/`short_id`、ws 传输 `path`(`/<hex>` 形式);替换时含其 16 字符前缀形式(info 页展示) | `<subtoken>#1`、`<rpub>#1`、`<secret>#3` |
| 随机端口 | 仅从字段 `port`/`exitPort`/`listen_port`/`server_port` 学习数值,JSON 中同名字段与文本中独立出现的该数字一并替换;`portRangeStart` 等确定性数字不学不换 | `<port>#2` |
| ss:// userinfo | SS 链接的 base64 userinfo 整体按出现顺序编号(其内容是 ssServerKey 与 relayPassword 的确定性函数) | `ss://<ssblob>#1@…` |
| 面板自身地址 | info 页里的 `http://127.0.0.1:<本侧端口>` | `<panel>` |
文本体(订阅)同样先归一化后逐字比;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 分钟)污染后续步。