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

9.9 KiB
Raw Permalink Blame History

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(差分主流程)。

快速开始

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 分钟)污染后续步。