feat: Go port of the panel in panel-go/ — drop-in replacement for server.js
CI / test (push) Canceled after 0s
CI / anytls-smoke (push) Canceled after 0s

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.
This commit is contained in:
aaaamoooo
2026-10-20 08:21:54 +00:00
parent abb1a4344b
commit 3951134940
37 changed files with 12183 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
# 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 分钟)污染后续步。