One credential set per customer (UUID + 32-byte root relay key + shortId)
lives on the customer record; node bindings no longer store credentials.
SS2022 user keys derive deterministically from the root key by method
length (16/24/32-byte prefix), so a single password serves every protocol,
matching 3x-ui's per-client credential model. Reality public keys stay a
node property and are shown per binding in the credentials pane.
- store: load-time convergence — first binding's set wins; a converged
password that is not a 32-byte root key is regenerated (uuid/shortId
preserved); bindings are stripped; idempotent, covered by tests
- deploy: the orchestrator stamps the customer's set onto a build copy;
subscriptions resolve the same set; the derivation applies in both the
inbound builder and the URI builder (verified live: Alice's N1 link is
byte-identical to the pre-change seed expectation, her N2 user key is
exactly the root key's 16-byte prefix)
- API: GET /api/customers/:id/credentials returns {credentials, bindings};
regenerate is now POST /api/customers/:id/credentials/regenerate and
rotates the whole set, rebuilding every deployed node of the customer
- UI: credentials pane shows the single shared set plus per-binding
node/protocol/Reality-public-key rows
- golden subscriptions fixture regenerated under one shared set (RF and
SS entries byte-identical to before; five entries audited by hand)
- golden baseline re-captured for the new shapes; difftest normalizer now
treats a base64 root key's prefix-truncation encodings and the Surge
'='-mangled variants as the same secret
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 路径与两边值摘要):
- 登录 / session → 种子 agent 心跳(不带
resources,不触发对账)→ overview → servers/customers/nodes/jobs 列表; - 新建服务器 S3 + enrollment + enroll → 新建客户 Carol → 新建节点 N3
(direct、
vless-reality)→ 分配 Carol → deploy → poll/complete (公钥同种子法)→ 节点列表断言 active; - Carol 订阅全格式两边逐字比(经归一化)→ Alice 订阅全格式两边比对,
且每边 body 与
seed-expect.json逐字比(rawEqSeed;userinfo 头不比 种子值,因为种子期望捕获于 usage 上报前); - usage 上报(Alice +500/+700,epoch
diff-phase2-epoch-1)→ customers 列表比对 usedBytes(应为 4200); - S3 agent heartbeat 带
resources: []触发对账 → poll 应拿到被重排的 apply(断言两边都有且 payload 语义等)→ complete 恢复 active; - 删除 N3(remove → poll delete → complete → 回到 draft)→ S3 forget 流程:在线 forget 应 409(有关联时与删关联后各一次)、改名确认应 400、 最后经 DELETE 服务器完成清理;
- backup 接口内容语义比对(整份 DB 快照经归一化深比);
- 错误用例组:未登录 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 分钟)污染后续步。