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.
This commit is contained in:
@@ -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 分钟)污染后续步。
|
||||
Reference in New Issue
Block a user