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 分钟)污染后续步。
+517
View File
@@ -0,0 +1,517 @@
// Shared harness for the NexusGate Node-vs-Go panel differential E2E tests.
//
// Only Node >= 18 standard library is used (fetch / fs / child_process / net).
// This module provides:
// - panel process management (start/stop, health wait, random free ports)
// - a small HTTP client that tracks the admin session cookie + CSRF token
// and can also speak as an agent (Bearer agentKey) or a subscriber
// - a "learning" normalizer: volatile values (prefixed UUID ids, bare
// UUIDs, ISO timestamps, tokens, hashes, Reality keys, per-node secrets,
// randomly allocated ports) are replaced by stable placeholders such as
// <cus>#2. Each side of a differential run owns one Normalizer instance;
// both instances are fed the same step sequence and walk objects with
// sorted keys, so the n-th distinct value of a category maps to the same
// placeholder on both sides even though the raw values differ.
// - job drain helpers that simulate a real agent (poll -> complete), where
// Reality apply jobs report artifacts.realityPublicKey taken from the
// polled payload.keyMaterial, exactly like the real agent materializing
// the panel-hosted keypair.
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const DIFFTEST_DIR = path.dirname(fileURLToPath(import.meta.url));
export const REPO_ROOT = path.resolve(DIFFTEST_DIR, '..', '..');
export const NODE_SERVER_JS = path.join(REPO_ROOT, 'server.js');
export const DEFAULT_GO_BIN = path.resolve(DIFFTEST_DIR, '..', 'nexusgate-panel-linux-amd64');
export const ADMIN_USERNAME = 'admin';
export const ADMIN_PASSWORD = 'diff-admin-pass';
// User-Agents used for subscription pulls. `auto` resolves to clash for
// clash-like UAs and to base64 otherwise (see formatSubscription).
export const UA_DEFAULT = 'NexusGate-DiffTest/1.0';
export const UA_CLASH = 'clash-verge/v2.0.0';
export const UA_GENERIC = 'Mozilla/5.0 (DiffTest)';
// All subscription shapes exercised by seed + phase 2, in a fixed order.
// `key` is the stable name used in seed-expect.json and step names.
export const SUB_CASES = [
{ key: 'raw', format: 'raw', ua: UA_DEFAULT },
{ key: 'base64', format: 'base64', ua: UA_DEFAULT },
{ key: 'clash', format: 'clash', ua: UA_DEFAULT },
{ key: 'clash-smart', format: 'clash-smart', ua: UA_DEFAULT },
{ key: 'singbox', format: 'singbox', ua: UA_DEFAULT },
{ key: 'surge', format: 'surge', ua: UA_DEFAULT },
{ key: 'auto-clash', format: 'auto', ua: UA_CLASH },
{ key: 'auto-generic', format: 'auto', ua: UA_GENERIC },
];
// ---------------------------------------------------------------------------
// Process management
// ---------------------------------------------------------------------------
export function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
server.close(() => resolve(port));
});
});
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Start one panel process.
* kind 'node' runs the Node panel (server.js); kind 'go' execs the Go binary.
* Both are configured purely through the NG_* environment contract:
* NG_HOST / NG_PORT / NG_DATA_FILE / NG_ADMIN_PASSWORD / NG_COOKIE_SECURE.
*/
export function startPanel({ kind, bin, dataFile, port }) {
const env = {
...process.env,
NG_HOST: '127.0.0.1',
NG_PORT: String(port),
NG_DATA_FILE: dataFile,
NG_ADMIN_PASSWORD: ADMIN_PASSWORD,
NG_COOKIE_SECURE: 'false',
};
let child;
if (kind === 'node') {
child = spawn(process.execPath, [NODE_SERVER_JS], { cwd: REPO_ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
} else {
child = spawn(bin, [], { env, stdio: ['ignore', 'pipe', 'pipe'] });
}
const proc = {
kind, child, port, dataFile,
base: `http://127.0.0.1:${port}`,
output: '',
exited: null,
};
const capture = (chunk) => { proc.output = (proc.output + chunk.toString()).slice(-8000); };
child.stdout.on('data', capture);
child.stderr.on('data', capture);
proc.exited = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal })));
return proc;
}
export async function waitHealth(proc, timeoutMs = 20000) {
const deadline = Date.now() + timeoutMs;
let exited = false;
proc.exited.then(() => { exited = true; });
while (Date.now() < deadline) {
if (exited) throw new Error(`panel (${proc.kind}) exited before becoming healthy:\n${proc.output}`);
try {
const res = await fetch(`${proc.base}/healthz`);
if (res.ok) { await res.text(); return; }
} catch { /* not up yet */ }
await sleep(100);
}
throw new Error(`panel (${proc.kind}) did not become healthy within ${timeoutMs}ms:\n${proc.output}`);
}
export async function stopPanel(proc) {
if (!proc || !proc.child) return;
if (proc.child.exitCode !== null || proc.child.signalCode !== null) return;
proc.child.kill('SIGTERM');
const done = await Promise.race([proc.exited.then(() => true), sleep(3000).then(() => false)]);
if (!done) {
proc.child.kill('SIGKILL');
await Promise.race([proc.exited, sleep(2000)]);
}
}
export function makeTempDir(prefix = 'nexusgate-difftest-') {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
// ---------------------------------------------------------------------------
// HTTP client (admin session / agent bearer / anonymous)
// ---------------------------------------------------------------------------
export class PanelClient {
constructor(base) {
this.base = base;
this.cookie = null;
this.csrf = null;
}
async request(method, pathname, { body, headers = {}, cookie, csrf } = {}) {
const finalHeaders = { ...headers };
const useCookie = cookie !== undefined ? cookie : this.cookie;
if (useCookie) finalHeaders.cookie = useCookie;
if (csrf) finalHeaders['x-csrf-token'] = this.csrf;
let payload;
if (body !== undefined) {
finalHeaders['content-type'] = 'application/json';
payload = JSON.stringify(body);
}
const res = await fetch(`${this.base}${pathname}`, { method, headers: finalHeaders, body: payload, redirect: 'manual' });
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch { /* HTML / plain text bodies */ }
return { status: res.status, headers: res.headers, text, json };
}
// Admin call. Non-GET requests carry the session CSRF token unless
// csrf:false is passed (used by the CSRF error-case step).
async admin(method, pathname, body, { csrf = true } = {}) {
return this.request(method, pathname, { body, csrf: csrf && method !== 'GET' });
}
async login(username = ADMIN_USERNAME, password = ADMIN_PASSWORD) {
const res = await this.request('POST', '/api/auth/login', { body: { username, password }, cookie: null });
if (res.status === 200 && res.json) {
const setCookie = res.headers.get('set-cookie') || '';
this.cookie = setCookie.split(';')[0];
this.csrf = res.json.csrf;
}
return res;
}
async agentPost(pathname, agentKey, body = {}) {
return this.request('POST', pathname, {
body, cookie: null, headers: { authorization: `Bearer ${agentKey}` },
});
}
async getSubscription(token, format, ua) {
const res = await fetch(`${this.base}/s/${token}/${format}`, {
headers: { 'user-agent': ua }, redirect: 'manual',
});
const text = await res.text();
return {
status: res.status,
contentType: res.headers.get('content-type') || '',
userinfo: res.headers.get('subscription-userinfo') || '',
retryAfter: res.headers.get('retry-after') || '',
body: text,
};
}
}
// ---------------------------------------------------------------------------
// Agent simulation helpers
// ---------------------------------------------------------------------------
// Artifacts a real agent would report for a completed job. For Reality apply
// jobs (entry leg only) the agent materializes the panel-hosted keypair and
// reports its public key; everything else reports no artifacts.
export function artifactsForJob(job) {
if (!job || job.action !== 'apply_resource') return {};
const payload = job.payload || {};
const kind = payload.kind || (payload.resource && payload.resource.meta && payload.resource.meta.kind);
if (kind === 'node-exit') return {};
const keyMaterial = payload.keyMaterial;
if (!keyMaterial || typeof keyMaterial !== 'object') return {};
const pair = keyMaterial[`reality-${job.nodeId}`] || Object.values(keyMaterial)[0];
return pair && pair.publicKey ? { realityPublicKey: pair.publicKey } : {};
}
export async function pollOnce(client, agentKey) {
const res = await client.agentPost('/api/agent/poll', agentKey, {});
if (res.status !== 200) throw new Error(`poll failed: ${res.status} ${res.text}`);
return res.json.job;
}
export async function completeJob(client, agentKey, job, { success = true } = {}) {
const res = await client.agentPost(`/api/agent/jobs/${job.id}/complete`, agentKey, {
success, result: { artifacts: artifactsForJob(job) },
});
return res;
}
// Poll + complete until the agent's queue is empty. Returns every
// { job, complete } pair in the order the jobs were picked up.
export async function drainAgentJobs(client, agentKey, { maxRounds = 40 } = {}) {
const done = [];
for (let round = 0; round < maxRounds; round += 1) {
const job = await pollOnce(client, agentKey);
if (!job) return done;
const complete = await completeJob(client, agentKey, job);
if (complete.status !== 200) {
throw new Error(`complete for job ${job.id} failed: ${complete.status} ${complete.text}`);
}
done.push({ job, complete: complete.json });
}
throw new Error(`agent queue did not drain within ${maxRounds} rounds`);
}
// ---------------------------------------------------------------------------
// Subscription helpers
// ---------------------------------------------------------------------------
// A base64 subscription body decodes to the raw URI list; comparing the
// decoded form is far more robust than comparing the opaque blob.
export function comparisonBody(caseKey, body) {
if (caseKey !== 'base64' && caseKey !== 'auto-generic') return body;
const compact = String(body).trim();
if (!/^[A-Za-z0-9+/=]+$/.test(compact) || compact.length % 4 !== 0) return body;
try {
const decoded = Buffer.from(compact, 'base64').toString('utf8');
return decoded.includes('://') ? decoded : body;
} catch {
return body;
}
}
export async function fetchAllSubscriptions(client, token) {
const out = {};
for (const c of SUB_CASES) {
const res = await client.getSubscription(token, c.format, c.ua);
out[c.key] = {
status: res.status,
contentType: res.contentType,
userinfo: res.userinfo,
body: comparisonBody(c.key, res.body),
};
}
return out;
}
// ---------------------------------------------------------------------------
// Learning normalizer
// ---------------------------------------------------------------------------
const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc'];
const PREFIXED_ID_RE = new RegExp(`\\b(${ID_PREFIXES.join('|')})_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`, 'g');
const BARE_UUID_RE = /(?<![0-9A-Za-z_-])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9A-Za-z_-])/g;
const ISO_TS_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})/g;
const LOCALE_DATE_RE = /\d{4}\/\d{1,2}\/\d{1,2}\s+\d{1,2}:\d{2}:\d{2}/g;
const SCRYPT_RE = /scrypt:[0-9a-f]+:[0-9a-f]+/g;
const SS_URI_RE = /ss:\/\/[A-Za-z0-9_-]+@/g;
// JSON field name -> placeholder category for high-entropy values that only
// ever appear under a recognizable field name.
const SECRET_FIELDS = {
subscriptionToken: 'subtoken',
agentKey: 'agentkey',
token: 'enrolltoken',
csrf: 'csrf',
passwordHash: 'hash',
tokenHash: 'hash',
keyHash: 'hash',
keyFingerprint: 'fingerprint',
clientKey: 'clientkey',
privateKey: 'rpriv',
publicKey: 'rpub',
realityPublicKey: 'rpub',
relayPassword: 'secret',
ssServerKey: 'secret',
wsPath: 'secret',
password: 'secret',
shortId: 'secret',
short_id: 'secret',
};
// Numeric fields whose values are randomly allocated ports. Only values
// under these field names are learned / replaced, so deterministic numbers
// (portRangeStart, usedBytes, traffic limits, ...) are never rewritten.
const PORT_FIELDS = new Set(['port', 'exitPort', 'listen_port', 'server_port']);
export class Normalizer {
constructor({ panelOrigin = '' } = {}) {
this.panelOrigin = panelOrigin;
this.counters = new Map(); // category -> last n
this.secrets = new Map(); // raw string -> placeholder (field-learned)
this.ids = new Map(); // `${prefix}` -> Map(raw -> placeholder)
this.fragments = new Map(); // derived id fragment -> owning id placeholder
this.uuids = new Map(); // raw -> placeholder
this.ports = new Map(); // number -> placeholder
this.hashes = new Map(); // raw scrypt string -> placeholder
this.blobs = new Map(); // ss:// userinfo blob -> placeholder
}
#assign(map, raw, label) {
let placeholder = map.get(raw);
if (!placeholder) {
const n = (this.counters.get(label) || 0) + 1;
this.counters.set(label, n);
placeholder = `<${label}>#${n}`;
map.set(raw, placeholder);
}
return placeholder;
}
#idPlaceholder(prefix, raw) {
if (!this.ids.has(prefix)) this.ids.set(prefix, new Map());
const placeholder = this.#assign(this.ids.get(prefix), raw, prefix);
// Derived forms: the panel builds sing-box tags from the node id with
// non-alphanumerics stripped, truncated to the last 18 chars
// (tagForNode -> "ng-<fragment>-in"). Register the fragment so those
// tags normalize through their owning id's placeholder.
if (prefix === 'nod') {
const fragment = raw.replace(/[^0-9a-z]/gi, '').slice(-18);
if (fragment && !this.fragments.has(fragment)) this.fragments.set(fragment, placeholder);
}
return placeholder;
}
// Feed one raw step result into the learning tables. Object keys are
// walked in sorted order so the learning order does not depend on either
// implementation's serialization order; arrays keep their semantic order.
learn(value, keyName = null) {
if (Array.isArray(value)) {
for (const item of value) this.learn(item, keyName);
return;
}
if (value && typeof value === 'object') {
for (const key of Object.keys(value).sort()) this.learn(value[key], key);
return;
}
if (typeof value === 'number') {
if (keyName && PORT_FIELDS.has(keyName) && Number.isInteger(value) && value >= 1 && value <= 65535) {
this.#assign(this.ports, value, 'port');
}
return;
}
if (typeof value !== 'string') return;
const category = SECRET_FIELDS[keyName];
if (category && value.length >= 4) this.#assign(this.secrets, value, category);
// ws paths appear as transport `path` in job payloads.
if (keyName === 'path' && /^\/[0-9a-f]{12,}$/.test(value)) this.#assign(this.secrets, value, 'secret');
for (const match of value.matchAll(PREFIXED_ID_RE)) this.#idPlaceholder(match[1], match[0]);
for (const match of value.matchAll(SCRYPT_RE)) this.#assign(this.hashes, match[0], 'hash');
// Bare UUIDs are learned only when the whole value is one (credentials);
// embedded ones are assigned on the fly during normalizeString, in the
// same textual order on both sides.
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)) {
this.#assign(this.uuids, value, 'uuid');
}
}
normalizeString(text) {
let out = String(text);
if (this.panelOrigin) out = out.split(this.panelOrigin).join('<panel>');
// Learned secrets first (longest raw first so a full token wins over its
// own prefixes), including the 16-char prefix shown on the info page.
const secrets = [...this.secrets.entries()].sort((a, b) => b[0].length - a[0].length);
for (const [raw, placeholder] of secrets) {
if (raw.length >= 8) out = out.split(raw).join(placeholder);
if (raw.length > 16) out = out.split(raw.slice(0, 16)).join(placeholder);
}
for (const [raw, placeholder] of this.hashes) out = out.split(raw).join(placeholder);
out = out.replace(PREFIXED_ID_RE, (whole, prefix) => this.#idPlaceholder(prefix, whole));
for (const [fragment, placeholder] of this.fragments) out = out.split(fragment).join(placeholder);
out = out.replace(BARE_UUID_RE, (whole) => this.#assign(this.uuids, whole, 'uuid'));
out = out.replace(SCRYPT_RE, (whole) => this.#assign(this.hashes, whole, 'hash'));
out = out.replace(ISO_TS_RE, '<ts>');
out = out.replace(LOCALE_DATE_RE, '<date>');
out = out.replace(SS_URI_RE, (whole) => `ss://${this.#assign(this.blobs, whole, 'ssblob')}@`);
// Learned ports as standalone numbers, longest digit string first.
const ports = [...this.ports.entries()].sort((a, b) => String(b[0]).length - String(a[0]).length);
for (const [num, placeholder] of ports) {
out = out.replace(new RegExp(`(?<![0-9])${num}(?![0-9])`, 'g'), placeholder);
}
return out;
}
normalize(value, keyName = null) {
if (Array.isArray(value)) return value.map((item) => this.normalize(item, keyName));
if (value && typeof value === 'object') {
// Sorted-key traversal (like learn) so placeholder assignment order is
// canonical and independent of either side's serialization order.
// firstDiff compares objects key-insensitively, so reordering is safe.
const out = {};
for (const key of Object.keys(value).sort()) out[this.normalizeString(key)] = this.normalize(value[key], key);
return out;
}
if (typeof value === 'number') {
if (keyName && PORT_FIELDS.has(keyName) && this.ports.has(value)) return this.ports.get(value);
return value;
}
if (typeof value === 'string') return this.normalizeString(value);
return value;
}
// Full pipeline for one step result: learn, then normalize.
process(value) {
this.learn(value);
return this.normalize(value);
}
normalizeText(text) {
return this.normalizeString(text);
}
}
// ---------------------------------------------------------------------------
// Deep diff + reporter
// ---------------------------------------------------------------------------
function summarize(value) {
let text;
try { text = JSON.stringify(value); } catch { text = String(value); }
if (text === undefined) text = String(value);
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
// Returns null when equal, otherwise the first difference found.
export function firstDiff(a, b, at = '$') {
if (Object.is(a, b)) return null;
if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') {
return { path: at, a: summarize(a), b: summarize(b) };
}
if (Array.isArray(a) !== Array.isArray(b)) return { path: at, a: summarize(a), b: summarize(b) };
if (Array.isArray(a)) {
if (a.length !== b.length) return { path: `${at}.length`, a: a.length, b: b.length };
for (let i = 0; i < a.length; i += 1) {
const diff = firstDiff(a[i], b[i], `${at}[${i}]`);
if (diff) return diff;
}
return null;
}
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
for (const key of keys) {
if (!(key in a)) return { path: `${at}.${key}`, a: '<absent>', b: summarize(b[key]) };
if (!(key in b)) return { path: `${at}.${key}`, a: summarize(a[key]), b: '<absent>' };
const diff = firstDiff(a[key], b[key], `${at}.${key}`);
if (diff) return diff;
}
return null;
}
export class Reporter {
constructor() {
this.total = 0;
this.passed = 0;
this.failed = 0;
this.skipped = 0;
this.failures = [];
}
pass(name) {
this.total += 1; this.passed += 1;
console.log(`PASS ${name}`);
}
fail(name, detail) {
this.total += 1; this.failed += 1;
this.failures.push({ name, detail });
console.log(`FAIL ${name} — ${detail}`);
}
skip(name, reason) {
this.total += 1; this.skipped += 1;
console.log(`SKIP ${name} — ${reason}`);
}
// Compare two already-normalized values as one step.
compare(name, a, b) {
const diff = firstDiff(a, b);
if (!diff) { this.pass(name); return true; }
this.fail(name, `${diff.path}: A=${diff.a} B=${diff.b}`);
return false;
}
summary(label) {
console.log(`SUMMARY ${label} total=${this.total} pass=${this.passed} fail=${this.failed} skip=${this.skipped}`);
}
}
+432
View File
@@ -0,0 +1,432 @@
// Differential E2E runner: Node panel vs Go panel.
//
// node run.mjs auto: formal mode when a Go binary is
// resolvable, otherwise selftest + SKIP
// node run.mjs --selftest Node vs Node (validates the harness
// itself; must report SELFTEST PASS)
// node run.mjs --bin <path> formal mode against this Go binary
// GO_PANEL_BIN=<path> node run.mjs formal mode against $GO_PANEL_BIN
// node run.mjs --keep keep temp dirs for debugging
//
// Binary resolution order: --bin > $GO_PANEL_BIN > panel-go/
// nexusgate-panel-linux-amd64 (built by the coordinator). When no binary
// exists, only the Node-vs-Node sanity runs and the formal mode is reported
// as SKIP (exit code still reflects the selftest result).
//
// Both sides start from byte-identical copies of seed.json (see seed.mjs;
// generated on demand when missing) and execute the same phase-2 script.
// Every response is fed through a per-side learning Normalizer (lib.mjs)
// before comparison, so random ids / tokens / ports / keys line up as
// <prefix>#n placeholders. Failures never abort the run: every step is
// executed and the first differing JSON path is reported per step.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient,
Reporter, UA_DEFAULT, completeJob, fetchAllSubscriptions, firstDiff,
freePort, makeTempDir, pollOnce, startPanel, stopPanel, waitHealth,
} from './lib.mjs';
import { generateSeed } from './seed.mjs';
const view = (res) => ({ status: res.status, body: res.json !== null && res.json !== undefined ? res.json : res.text });
// ---------------------------------------------------------------------------
// Side lifecycle
// ---------------------------------------------------------------------------
async function makeSide({ kind, bin, seedPath, label }) {
const tmpDir = makeTempDir(`nexusgate-diff-${label}-`);
const dataFile = path.join(tmpDir, 'nexusgate.json');
fs.copyFileSync(seedPath, dataFile);
const port = await freePort();
const proc = startPanel({ kind, bin, dataFile, port });
await waitHealth(proc);
const side = {
label, kind, bin, tmpDir, dataFile, port,
proc, base: proc.base,
client: new PanelClient(proc.base),
norm: new Normalizer({ panelOrigin: proc.base }),
ids: {}, agentKeys: {}, subTokens: {}, pendingJob: null,
};
return side;
}
async function restartSide(side) {
await stopPanel(side.proc);
side.proc = startPanel({ kind: side.kind, bin: side.bin, dataFile: side.dataFile, port: side.port });
side.base = side.proc.base;
await waitHealth(side.proc);
side.client = new PanelClient(side.base);
side.norm = new Normalizer({ panelOrigin: side.base });
side.pendingJob = null;
}
async function destroySide(side, keep) {
await stopPanel(side.proc).catch(() => {});
if (!keep) fs.rmSync(side.tmpDir, { recursive: true, force: true });
}
// ---------------------------------------------------------------------------
// Step runner: executes the same async step body against both sides and
// compares the normalized results.
// ---------------------------------------------------------------------------
class StepRunner {
constructor(reporter, sideA, sideB) {
this.reporter = reporter;
this.sideA = sideA;
this.sideB = sideB;
}
async step(name, run) {
let ra; let rb; let ea = null; let eb = null;
try { ra = await run(this.sideA); } catch (error) { ea = error; }
try { rb = await run(this.sideB); } catch (error) { eb = error; }
if (ea || eb) {
this.reporter.fail(name, `exception A=${ea ? ea.message : '-'} B=${eb ? eb.message : '-'}`);
return { ra, rb };
}
const na = this.sideA.norm.process(ra);
const nb = this.sideB.norm.process(rb);
this.reporter.compare(name, na, nb);
return { ra, rb };
}
}
// ---------------------------------------------------------------------------
// Phase 2 script (identical for selftest and formal mode)
// ---------------------------------------------------------------------------
async function runPhase2(runner, { seedLog, seedExpect }) {
const seedIds = seedLog.meta.ids;
// Seed-state wiring: raw ids and agent keys are identical on both sides
// because both DBs are copies of the same seed file.
for (const side of [runner.sideA, runner.sideB]) {
side.ids.entryServer = seedIds.entryServer;
side.ids.exitServer = seedIds.exitServer;
side.ids.n1 = seedIds.n1;
side.ids.n2 = seedIds.n2;
side.agentKeys.entry = seedLog.meta.agentKeys.entry;
side.agentKeys.exit = seedLog.meta.agentKeys.exit;
}
await runner.step('p2/login', async (side) => view(await side.client.login()));
await runner.step('p2/session', async (side) => view(await side.client.admin('GET', '/api/session')));
await runner.step('p2/heartbeat-seed-agents', async (side) => {
const entry = await side.client.agentPost('/api/agent/heartbeat', side.agentKeys.entry, { version: '0.6.8', system: {} });
const exit = await side.client.agentPost('/api/agent/heartbeat', side.agentKeys.exit, { version: '0.6.8', system: {} });
return { entry: view(entry), exit: view(exit) };
});
await runner.step('p2/overview', async (side) => view(await side.client.admin('GET', '/api/overview')));
await runner.step('p2/list-servers', async (side) => view(await side.client.admin('GET', '/api/servers')));
await runner.step('p2/list-customers', async (side) => {
const res = await side.client.admin('GET', '/api/customers');
for (const customer of res.json.customers) {
const logical = customer.name.toLowerCase();
side.ids[logical] = customer.id;
side.subTokens[logical] = customer.subscriptionToken;
}
return view(res);
});
await runner.step('p2/list-nodes', async (side) => view(await side.client.admin('GET', '/api/nodes')));
await runner.step('p2/list-jobs', async (side) => view(await side.client.admin('GET', '/api/jobs')));
await runner.step('p2/create-server-s3', async (side) => {
const res = await side.client.admin('POST', '/api/servers', {
name: 'S3', role: 'relay', region: '', publicAddress: 's3.example.com',
publicAddressV6: '', tlsDomain: '', portRangeStart: 20000, portRangeEnd: 50000,
});
if (res.json && res.json.server) side.ids.s3 = res.json.server.id;
return view(res);
});
await runner.step('p2/enroll-s3', async (side) => {
const ticket = await side.client.admin('POST', `/api/servers/${side.ids.s3}/enrollment-token`, {});
const enrolled = await side.client.request('POST', '/api/agent/enroll', {
body: { token: ticket.json.token, hostname: 'diff-agent-s3', version: '0.6.8', system: {} },
cookie: null,
});
if (enrolled.json && enrolled.json.agentKey) side.agentKeys.s3 = enrolled.json.agentKey;
return { ticket: view(ticket), enroll: view(enrolled) };
});
await runner.step('p2/create-customer-carol', async (side) => {
const res = await side.client.admin('POST', '/api/customers', { name: 'Carol' });
if (res.json && res.json.customer) {
side.ids.carol = res.json.customer.id;
side.subTokens.carol = res.json.customer.subscriptionToken;
}
return view(res);
});
await runner.step('p2/create-node-n3', async (side) => {
const res = await side.client.admin('POST', '/api/nodes', {
name: 'N3', serverId: side.ids.s3, topology: 'direct',
protocol: 'vless-reality', realityServerName: 'www.amd.com',
});
if (res.json && res.json.node) side.ids.n3 = res.json.node.id;
return view(res);
});
await runner.step('p2/assign-n3-carol', async (side) =>
view(await side.client.admin('POST', `/api/nodes/${side.ids.n3}/assign`, { customerId: side.ids.carol })));
await runner.step('p2/deploy-n3', async (side) =>
view(await side.client.admin('POST', `/api/nodes/${side.ids.n3}/deploy`, {})));
await runner.step('p2/poll-n3-apply', async (side) => {
const job = await pollOnce(side.client, side.agentKeys.s3);
side.pendingJob = job;
return { job };
});
await runner.step('p2/complete-n3-apply', async (side) => {
if (!side.pendingJob) return { skipped: 'no job was polled' };
return view(await completeJob(side.client, side.agentKeys.s3, side.pendingJob));
});
await runner.step('p2/list-nodes-n3-active', async (side) => view(await side.client.admin('GET', '/api/nodes')));
await runner.step('p2/carol-subscriptions', async (side) =>
fetchAllSubscriptions(side.client, side.subTokens.carol));
await runner.step('p2/alice-subscriptions', async (side) => {
const subs = await fetchAllSubscriptions(side.client, side.subTokens.alice);
// Bodies must also match the golden seed expectations byte-for-byte
// (comparison form): both DBs are copies of the seed, and nothing in
// phase 2 up to this point changes Alice's node credentials. The
// subscription-userinfo header is excluded here because the seed
// capture predates the seed usage report.
const rawEqSeed = {};
for (const [key, sub] of Object.entries(subs)) {
const expected = seedExpect.alice[key];
rawEqSeed[key] = Boolean(expected) && sub.status === expected.status && sub.body === expected.body;
}
return { ...subs, rawEqSeed };
});
await runner.step('p2/usage-report-alice', async (side) =>
view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
epoch: 'diff-phase2-epoch-1',
samples: [{ kind: 'user', id: side.ids.alice, uplink: 500, downlink: 700 }],
})));
await runner.step('p2/list-customers-after-usage', async (side) =>
view(await side.client.admin('GET', '/api/customers')));
// Heartbeat reconciliation: the S3 agent reports holding no resources,
// so the panel must re-queue the apply for N3 (which it believes is
// deployed there) with an equivalent payload.
await runner.step('p2/heartbeat-s3-reconcile', async (side) =>
view(await side.client.agentPost('/api/agent/heartbeat', side.agentKeys.s3, {
version: '0.6.8', system: {}, resources: [],
})));
await runner.step('p2/poll-n3-reconciled-apply', async (side) => {
const job = await pollOnce(side.client, side.agentKeys.s3);
side.pendingJob = job;
return { job };
});
await runner.step('p2/complete-n3-reconciled', async (side) => {
if (!side.pendingJob) return { skipped: 'no reconciled job was polled' };
return view(await completeJob(side.client, side.agentKeys.s3, side.pendingJob));
});
// Remove N3: undeploy -> agent polls the delete job -> complete.
await runner.step('p2/remove-n3', async (side) =>
view(await side.client.admin('POST', `/api/nodes/${side.ids.n3}/remove`, {})));
await runner.step('p2/poll-n3-delete', async (side) => {
const job = await pollOnce(side.client, side.agentKeys.s3);
side.pendingJob = job;
return { job };
});
await runner.step('p2/complete-n3-delete', async (side) => {
if (!side.pendingJob) return { skipped: 'no delete job was polled' };
return view(await completeJob(side.client, side.agentKeys.s3, side.pendingJob));
});
await runner.step('p2/list-nodes-after-remove', async (side) => view(await side.client.admin('GET', '/api/nodes')));
// S3 forget flow: an online server cannot be forgotten (409), before or
// after its node association is deleted; the name confirmation is
// validated too (400). Cleanup finishes through the normal DELETE route.
// (A successful forget requires the server to be offline, which the
// panel only derives after >180s without a heartbeat — not reachable
// inside this run; see README.)
await runner.step('p2/forget-s3-online-with-node', async (side) =>
view(await side.client.admin('POST', `/api/servers/${side.ids.s3}/forget`, { confirm: 'FORGET', name: 'S3' })));
await runner.step('p2/delete-node-n3', async (side) =>
view(await side.client.admin('DELETE', `/api/nodes/${side.ids.n3}`)));
await runner.step('p2/forget-s3-wrong-name', async (side) =>
view(await side.client.admin('POST', `/api/servers/${side.ids.s3}/forget`, { confirm: 'FORGET', name: 'NotS3' })));
await runner.step('p2/forget-s3-online', async (side) =>
view(await side.client.admin('POST', `/api/servers/${side.ids.s3}/forget`, { confirm: 'FORGET', name: 'S3' })));
await runner.step('p2/delete-server-s3', async (side) =>
view(await side.client.admin('DELETE', `/api/servers/${side.ids.s3}`)));
await runner.step('p2/list-servers-final', async (side) => view(await side.client.admin('GET', '/api/servers')));
await runner.step('p2/backup', async (side) => {
const res = await side.client.admin('GET', '/api/backup');
let parsed = null;
try { parsed = JSON.parse(res.text); } catch { /* reported via status/body below */ }
return { status: res.status, body: parsed !== null ? parsed : res.text };
});
// --- error cases ---------------------------------------------------------
await runner.step('err/unauthenticated-401', async (side) =>
view(await side.client.request('GET', '/api/customers', { cookie: null })));
await runner.step('err/csrf-missing-403', async (side) =>
view(await side.client.admin('POST', '/api/customers', { name: 'ShouldNotExist' }, { csrf: false })));
await runner.step('err/login-failed-401', async (side) =>
view(await side.client.request('POST', '/api/auth/login', {
body: { username: ADMIN_USERNAME, password: 'wrong-password' }, cookie: null,
})));
await runner.step('err/node-not-found-404', async (side) =>
view(await side.client.admin('POST', '/api/nodes/nod_00000000-0000-0000-0000-000000000000/deploy', {})));
await runner.step('err/subscription-bad-token-404', async (side) => {
const res = await side.client.getSubscription('A'.repeat(43), 'raw', UA_DEFAULT);
return { status: res.status, body: res.body };
});
await runner.step('err/subscription-info-page', async (side) => {
const res = await side.client.getSubscription(side.subTokens.alice, 'info', UA_DEFAULT);
return { status: res.status, body: res.body };
});
}
// ---------------------------------------------------------------------------
// DB interoperability: swap the two panels' DB files and keep working.
// ---------------------------------------------------------------------------
async function runInterop(reporter, sideA, sideB, { seedExpect }) {
const statsRunner = new StepRunner(reporter, sideA, sideB);
await statsRunner.step('interop/db-stats-before-swap', async (side) => {
const db = JSON.parse(fs.readFileSync(side.dataFile, 'utf8'));
const alice = db.customers.find((item) => item.name === 'Alice');
return {
keyStoreCount: Object.keys(db.keyStore || {}).length,
jobsCount: db.jobs.length,
aliceUsedBytes: alice ? alice.usedBytes : null,
};
});
// Swap the database files and restart each implementation on the other's
// data. Fresh clients + normalizers: appearance order restarts here.
const aRaw = fs.readFileSync(sideA.dataFile);
const bRaw = fs.readFileSync(sideB.dataFile);
await stopPanel(sideA.proc);
await stopPanel(sideB.proc);
fs.writeFileSync(sideA.dataFile, bRaw);
fs.writeFileSync(sideB.dataFile, aRaw);
await restartSide(sideA);
await restartSide(sideB);
const runner = new StepRunner(reporter, sideA, sideB);
await runner.step('interop/login', async (side) => view(await side.client.login()));
await runner.step('interop/list-servers', async (side) => view(await side.client.admin('GET', '/api/servers')));
await runner.step('interop/list-customers', async (side) => {
const res = await side.client.admin('GET', '/api/customers');
for (const customer of res.json.customers) {
const logical = customer.name.toLowerCase();
side.ids[logical] = customer.id;
side.subTokens[logical] = customer.subscriptionToken;
}
return view(res);
});
await runner.step('interop/list-nodes', async (side) => view(await side.client.admin('GET', '/api/nodes')));
await runner.step('interop/list-jobs', async (side) => view(await side.client.admin('GET', '/api/jobs')));
await runner.step('interop/alice-subscriptions', async (side) => {
const subs = await fetchAllSubscriptions(side.client, side.subTokens.alice);
const rawEqSeed = {};
for (const [key, sub] of Object.entries(subs)) {
const expected = seedExpect.alice[key];
rawEqSeed[key] = Boolean(expected) && sub.status === expected.status && sub.body === expected.body;
}
return { ...subs, rawEqSeed };
});
await runner.step('interop/patch-alice-notes', async (side) =>
view(await side.client.admin('PATCH', `/api/customers/${side.ids.alice}`, { notes: 'interop-ok' })));
await runner.step('interop/list-customers-after-notes', async (side) =>
view(await side.client.admin('GET', '/api/customers')));
}
// ---------------------------------------------------------------------------
// Modes
// ---------------------------------------------------------------------------
async function runPair({ reporter, kindA, kindB, bin, seedPath, seedLog, seedExpect, keep, label }) {
let sideA = null;
let sideB = null;
try {
sideA = await makeSide({ kind: kindA, bin, seedPath, label: `${label}-a` });
sideB = await makeSide({ kind: kindB, bin, seedPath, label: `${label}-b` });
} catch (error) {
reporter.fail(`setup/${label}`, error.message);
if (sideA) await destroySide(sideA, keep);
if (sideB) await destroySide(sideB, keep);
return false;
}
const failuresBefore = reporter.failed;
try {
const runner = new StepRunner(reporter, sideA, sideB);
await runPhase2(runner, { seedLog, seedExpect });
await runInterop(reporter, sideA, sideB, { seedExpect });
} finally {
await destroySide(sideA, keep);
await destroySide(sideB, keep);
}
return reporter.failed === failuresBefore;
}
async function main() {
const args = process.argv.slice(2);
let argBin = null;
let seedPath = path.join(DIFFTEST_DIR, 'seed.json');
let forceSelftest = false;
let keep = false;
for (let i = 0; i < args.length; i += 1) {
if (args[i] === '--bin' && args[i + 1]) { argBin = args[i + 1]; i += 1; }
else if (args[i] === '--seed' && args[i + 1]) { seedPath = path.resolve(args[i + 1]); i += 1; }
else if (args[i] === '--selftest') forceSelftest = true;
else if (args[i] === '--keep') keep = true;
}
const goBin = argBin || process.env.GO_PANEL_BIN || DEFAULT_GO_BIN;
const goBinExists = fs.existsSync(goBin);
if (!fs.existsSync(seedPath)) {
console.log(`[run] seed not found at ${seedPath}; generating it with the Node panel first`);
await generateSeed({ outDir: path.dirname(seedPath), quiet: false });
}
const seedLog = JSON.parse(fs.readFileSync(path.join(path.dirname(seedPath), 'seed-log.json'), 'utf8'));
const seedExpect = JSON.parse(fs.readFileSync(path.join(path.dirname(seedPath), 'seed-expect.json'), 'utf8'));
const reporter = new Reporter();
let selftestOk = null;
let formalOk = null;
if (forceSelftest || !goBinExists) {
console.log('[run] SELFTEST: Node vs Node (same seed, same phase-2 script)');
selftestOk = await runPair({
reporter, kindA: 'node', kindB: 'node', seedPath, seedLog, seedExpect, keep, label: 'self',
});
reporter.summary('selftest');
console.log(`SELFTEST ${selftestOk ? 'PASS' : 'FAIL'}`);
}
if (!forceSelftest) {
if (goBinExists) {
console.log(`[run] FORMAL: Node vs Go (${goBin})`);
formalOk = await runPair({
reporter, kindA: 'node', kindB: 'go', bin: goBin, seedPath, seedLog, seedExpect, keep, label: 'formal',
});
reporter.summary('formal');
console.log(`FORMAL ${formalOk ? 'PASS' : 'FAIL'}`);
} else {
reporter.skip('formal/go-panel', `Go panel binary not found at ${goBin} (pass --bin <path> or set GO_PANEL_BIN); ran Node-vs-Node selftest only`);
console.log(`FORMAL SKIP (Go panel binary not found at ${goBin})`);
reporter.summary('formal');
}
}
const failed = reporter.failed > 0 || selftestOk === false || formalOk === false;
process.exitCode = failed ? 1 : 0;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
export { firstDiff };
+52
View File
@@ -0,0 +1,52 @@
{
"alice": {
"raw": {
"status": 200,
"contentType": "text/plain; charset=utf-8",
"userinfo": "upload=0; download=0; total=0; expire=0",
"body": "vless://631268da-ea76-479c-b9f6-9cf2c57825f6@entry.example.com:34035?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg&sid=45584502b5567121&type=tcp&headerType=none&flow=xtls-rprx-vision#N1\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206eE9jQjdYR2dvNlZwYUVIWmZOWHJsUT09OnpzRlFyTEEzMWNITTFVU1FWZ0h1WHc9PQ@entry.example.com:29256#N2\n"
},
"base64": {
"status": 200,
"contentType": "text/plain; charset=utf-8",
"userinfo": "upload=0; download=0; total=0; expire=0",
"body": "vless://631268da-ea76-479c-b9f6-9cf2c57825f6@entry.example.com:34035?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg&sid=45584502b5567121&type=tcp&headerType=none&flow=xtls-rprx-vision#N1\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206eE9jQjdYR2dvNlZwYUVIWmZOWHJsUT09OnpzRlFyTEEzMWNITTFVU1FWZ0h1WHc9PQ@entry.example.com:29256#N2\n"
},
"clash": {
"status": 200,
"contentType": "text/yaml; charset=utf-8",
"userinfo": "upload=0; download=0; total=0; expire=0",
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"N1\"\n server: \"entry.example.com\"\n port: 34035\n type: \"vless\"\n uuid: \"631268da-ea76-479c-b9f6-9cf2c57825f6\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg\"\n short-id: \"45584502b5567121\"\n flow: \"xtls-rprx-vision\"\n - name: \"N2\"\n server: \"entry.example.com\"\n port: 29256\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"xOcB7XGgo6VpaEHZfNXrlQ==:zsFQrLA31cHM1USQVgHuXw==\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"N1\"\n - \"N2\"\nrules:\n - \"MATCH,节点选择\"\n"
},
"clash-smart": {
"status": 200,
"contentType": "text/yaml; charset=utf-8",
"userinfo": "upload=0; download=0; total=0; expire=0",
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"N1\"\n server: \"entry.example.com\"\n port: 34035\n type: \"vless\"\n uuid: \"631268da-ea76-479c-b9f6-9cf2c57825f6\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg\"\n short-id: \"45584502b5567121\"\n flow: \"xtls-rprx-vision\"\n - name: \"N2\"\n server: \"entry.example.com\"\n port: 29256\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"xOcB7XGgo6VpaEHZfNXrlQ==:zsFQrLA31cHM1USQVgHuXw==\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"自动选择\"\n - \"N1\"\n - \"N2\"\n - \"DIRECT\"\n - name: \"自动选择\"\n type: url-test\n url: \"https://www.gstatic.com/generate_204\"\n interval: 300\n tolerance: 80\n proxies:\n - \"N1\"\n - \"N2\"\n - name: \"流媒体\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"N1\"\n - \"N2\"\n - name: \"AI 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"N1\"\n - \"N2\"\n - name: \"Meta 服务\"\n type: select\n proxies:\n - \"节点选择\"\n - \"自动选择\"\n - \"N1\"\n - \"N2\"\nrules:\n - \"GEOSITE,category-ads-all,REJECT\"\n - \"GEOSITE,private,DIRECT\"\n - \"GEOSITE,openai,AI 服务\"\n - \"GEOSITE,netflix,流媒体\"\n - \"GEOSITE,facebook,Meta 服务\"\n - \"DOMAIN-SUFFIX,fbcdn.net,Meta 服务\"\n - \"DOMAIN-SUFFIX,instagram.com,Meta 服务\"\n - \"DOMAIN-SUFFIX,whatsapp.net,Meta 服务\"\n - \"GEOSITE,geolocation-!cn,节点选择\"\n - \"GEOSITE,cn,DIRECT\"\n - \"GEOIP,CN,DIRECT\"\n - \"MATCH,节点选择\"\n"
},
"singbox": {
"status": 200,
"contentType": "application/json; charset=utf-8",
"userinfo": "upload=0; download=0; total=0; expire=0",
"body": "{\n \"log\": {\n \"level\": \"warn\"\n },\n \"inbounds\": [\n {\n \"type\": \"mixed\",\n \"tag\": \"local\",\n \"listen\": \"127.0.0.1\",\n \"listen_port\": 2080\n }\n ],\n \"outbounds\": [\n {\n \"type\": \"selector\",\n \"tag\": \"proxy\",\n \"outbounds\": [\n \"node-1\",\n \"node-2\"\n ]\n },\n {\n \"type\": \"vless\",\n \"tag\": \"node-1\",\n \"server\": \"entry.example.com\",\n \"server_port\": 34035,\n \"uuid\": \"631268da-ea76-479c-b9f6-9cf2c57825f6\",\n \"flow\": \"xtls-rprx-vision\",\n \"tls\": {\n \"enabled\": true,\n \"server_name\": \"www.amd.com\",\n \"reality\": {\n \"enabled\": true,\n \"public_key\": \"QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg\",\n \"short_id\": \"45584502b5567121\"\n }\n }\n },\n {\n \"type\": \"shadowsocks\",\n \"tag\": \"node-2\",\n \"server\": \"entry.example.com\",\n \"server_port\": 29256,\n \"method\": \"2022-blake3-aes-128-gcm\",\n \"password\": \"xOcB7XGgo6VpaEHZfNXrlQ==:zsFQrLA31cHM1USQVgHuXw==\"\n },\n {\n \"type\": \"direct\",\n \"tag\": \"direct\"\n }\n ],\n \"route\": {\n \"final\": \"proxy\"\n }\n}\n"
},
"surge": {
"status": 200,
"contentType": "text/plain; charset=utf-8",
"userinfo": "upload=0; download=0; total=0; expire=0",
"body": "[General]\nloglevel = notify\n[Proxy]\nN2 = ss, entry.example.com, 29256, encrypt-method=2022-blake3-aes-128-gcm, password=xOcB7XGgo6VpaEHZfNXrlQ__:zsFQrLA31cHM1USQVgHuXw__\n[Proxy Group]\n节点选择 = select, N2, DIRECT\n自动选择 = url-test, N2, url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n"
},
"auto-clash": {
"status": 200,
"contentType": "text/yaml; charset=utf-8",
"userinfo": "upload=0; download=0; total=0; expire=0",
"body": "mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n - name: \"N1\"\n server: \"entry.example.com\"\n port: 34035\n type: \"vless\"\n uuid: \"631268da-ea76-479c-b9f6-9cf2c57825f6\"\n tls: true\n network: \"tcp\"\n servername: \"www.amd.com\"\n client-fingerprint: \"chrome\"\n reality-opts: \n public-key: \"QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg\"\n short-id: \"45584502b5567121\"\n flow: \"xtls-rprx-vision\"\n - name: \"N2\"\n server: \"entry.example.com\"\n port: 29256\n type: \"ss\"\n cipher: \"2022-blake3-aes-128-gcm\"\n password: \"xOcB7XGgo6VpaEHZfNXrlQ==:zsFQrLA31cHM1USQVgHuXw==\"\nproxy-groups:\n - name: \"节点选择\"\n type: select\n proxies:\n - \"N1\"\n - \"N2\"\nrules:\n - \"MATCH,节点选择\"\n"
},
"auto-generic": {
"status": 200,
"contentType": "text/plain; charset=utf-8",
"userinfo": "upload=0; download=0; total=0; expire=0",
"body": "vless://631268da-ea76-479c-b9f6-9cf2c57825f6@entry.example.com:34035?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=QAObwuCHabzq4I5rKawYdMvlEXDSCHDOpljFD3YYkjg&sid=45584502b5567121&type=tcp&headerType=none&flow=xtls-rprx-vision#N1\nss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206eE9jQjdYR2dvNlZwYUVIWmZOWHJsUT09OnpzRlFyTEEzMWNITTFVU1FWZ0h1WHc9PQ@entry.example.com:29256#N2\n"
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+256
View File
@@ -0,0 +1,256 @@
// Golden-seed generator for the NexusGate panel differential tests.
//
// Starts the NODE panel on a random high port with a throwaway data file
// (NG_DATA_FILE inside an fs.mkdtemp directory — the repository data/ dir is
// never touched) and NG_ADMIN_PASSWORD=diff-admin-pass, then drives the full
// business flow through the public HTTP APIs only:
//
// login
// -> create entry server (entry.example.com / v6.example.com /
// tls.example.com, ports 20000-50000) and exit server (exit.example.com)
// -> enrollment-token + simulated agent enroll for both servers
// -> customers Alice (unlimited) and Bob (1 GiB, ipLimit 5, deviceLimit 2)
// -> nodes N1 (forward, vless-reality-vision, exit
// shadowsocks-2022-aes256, realityServerName www.amd.com) and
// N2 (direct, shadowsocks-2022-aes128)
// -> assign N1: Alice+Bob, N2: Alice; deploy both
// -> agents poll and complete apply jobs (Reality public key reported
// from payload.keyMaterial, like the real agent)
// -> assert both nodes active
// -> capture Alice's subscriptions in every format as expectations
// -> report usage (user sample for Alice, inbound sample for N1)
// and one observation (one IP for Alice)
// -> export the panel DB as seed.json
//
// Outputs (in --out-dir, default: this directory):
// seed.json the exported panel database (the golden seed)
// seed-log.json every request/response of the run + recovered agentKeys
// (run.mjs needs the raw seed agentKeys to keep driving
// the seed agents in phase 2; they only exist in the
// enroll responses, the DB stores hashes)
// seed-expect.json Alice's subscription responses before the usage report
// (bodies stored in comparison form: base64 cases are
// stored decoded, matching fetchAllSubscriptions in
// lib.mjs, so run.mjs can compare them directly)
//
// Usage: node seed.mjs [--out-dir <dir>]
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
ADMIN_PASSWORD, ADMIN_USERNAME, PanelClient, DIFFTEST_DIR,
drainAgentJobs, fetchAllSubscriptions, freePort, makeTempDir,
startPanel, stopPanel, waitHealth,
} from './lib.mjs';
// Deterministic seed traffic numbers. After the usage report Alice must
// have usedBytes = 1000 + 2000 = 3000 and N1 meteredTraffic 4000/5000.
export const SEED_USAGE = {
epoch: 'diff-epoch-1',
user: { uplink: 1000, downlink: 2000 },
inbound: { uplink: 4000, downlink: 5000 },
aliceIp: '203.0.113.10',
};
export async function generateSeed({ outDir = DIFFTEST_DIR, quiet = false } = {}) {
const log = (...args) => { if (!quiet) console.log(...args); };
const tmpDir = makeTempDir('nexusgate-seed-');
const dataFile = path.join(tmpDir, 'nexusgate.json');
const port = await freePort();
const proc = startPanel({ kind: 'node', dataFile, port });
const client = new PanelClient(proc.base);
const steps = [];
const ids = {};
const agentKeys = {};
let aliceSubs = null;
const record = (name, request, res) => {
steps.push({
name,
request,
response: { status: res.status, body: res.json !== null && res.json !== undefined ? res.json : res.text },
});
};
const must = (cond, what, extra = '') => {
if (!cond) throw new Error(`seed step failed: ${what} ${extra}`);
};
const admin = async (name, method, pathname, body) => {
const res = await client.admin(method, pathname, body);
record(name, { method, pathname, body }, res);
return res;
};
try {
await waitHealth(proc);
log(`[seed] node panel on ${proc.base} (data ${dataFile})`);
// --- login -------------------------------------------------------------
const login = await client.login();
record('login', { method: 'POST', pathname: '/api/auth/login', body: { username: ADMIN_USERNAME } }, login);
must(login.status === 200, 'login', login.text);
// --- servers -----------------------------------------------------------
const entryRes = await admin('create-server-entry', 'POST', '/api/servers', {
name: 'Entry', role: 'relay', region: '',
publicAddress: 'entry.example.com', publicAddressV6: 'v6.example.com',
tlsDomain: 'tls.example.com', portRangeStart: 20000, portRangeEnd: 50000,
});
must(entryRes.status === 201, 'create entry server', entryRes.text);
ids.entryServer = entryRes.json.server.id;
const exitRes = await admin('create-server-exit', 'POST', '/api/servers', {
name: 'Exit', role: 'exit', region: '',
publicAddress: 'exit.example.com', publicAddressV6: '',
tlsDomain: '', portRangeStart: 20000, portRangeEnd: 50000,
});
must(exitRes.status === 201, 'create exit server', exitRes.text);
ids.exitServer = exitRes.json.server.id;
// --- enroll both agents --------------------------------------------------
for (const [label, serverId] of [['entry', ids.entryServer], ['exit', ids.exitServer]]) {
const ticket = await admin(`enrollment-token-${label}`, 'POST', `/api/servers/${serverId}/enrollment-token`, {});
must(ticket.status === 201, `enrollment token (${label})`, ticket.text);
const enrolled = await client.request('POST', '/api/agent/enroll', {
body: { token: ticket.json.token, hostname: `diff-agent-${label}`, version: '0.6.8', system: {} },
cookie: null,
});
record(`enroll-${label}`, { method: 'POST', pathname: '/api/agent/enroll' }, enrolled);
must(enrolled.status === 201, `enroll (${label})`, enrolled.text);
agentKeys[label] = enrolled.json.agentKey;
ids[`${label}Agent`] = enrolled.json.agentId;
const hb = await client.agentPost('/api/agent/heartbeat', agentKeys[label], { version: '0.6.8', system: {} });
record(`heartbeat-${label}`, { method: 'POST', pathname: '/api/agent/heartbeat' }, hb);
must(hb.status === 200, `heartbeat (${label})`, hb.text);
}
// --- customers -----------------------------------------------------------
const aliceRes = await admin('create-customer-alice', 'POST', '/api/customers', { name: 'Alice' });
must(aliceRes.status === 201, 'create Alice', aliceRes.text);
ids.alice = aliceRes.json.customer.id;
const aliceToken = aliceRes.json.customer.subscriptionToken;
const bobRes = await admin('create-customer-bob', 'POST', '/api/customers', {
name: 'Bob', trafficLimitBytes: 1073741824, ipLimit: 5, deviceLimit: 2,
});
must(bobRes.status === 201, 'create Bob', bobRes.text);
ids.bob = bobRes.json.customer.id;
// --- nodes ---------------------------------------------------------------
const n1Res = await admin('create-node-n1', 'POST', '/api/nodes', {
name: 'N1', serverId: ids.entryServer, topology: 'forward',
protocol: 'vless-reality-vision', exitServerId: ids.exitServer,
exitProtocol: 'shadowsocks-2022-aes256', realityServerName: 'www.amd.com',
});
must(n1Res.status === 201, 'create N1', n1Res.text);
ids.n1 = n1Res.json.node.id;
const n2Res = await admin('create-node-n2', 'POST', '/api/nodes', {
name: 'N2', serverId: ids.entryServer, topology: 'direct',
protocol: 'shadowsocks-2022-aes128',
});
must(n2Res.status === 201, 'create N2', n2Res.text);
ids.n2 = n2Res.json.node.id;
// --- assignments ---------------------------------------------------------
for (const [nodeLabel, nodeId, customerLabel, customerId] of [
['n1', ids.n1, 'alice', ids.alice],
['n1', ids.n1, 'bob', ids.bob],
['n2', ids.n2, 'alice', ids.alice],
]) {
const res = await admin(`assign-${nodeLabel}-${customerLabel}`, 'POST', `/api/nodes/${nodeId}/assign`, { customerId });
must(res.status === 200, `assign ${nodeLabel}/${customerLabel}`, res.text);
}
// --- deploy + drain ------------------------------------------------------
for (const [label, nodeId] of [['n1', ids.n1], ['n2', ids.n2]]) {
const res = await admin(`deploy-${label}`, 'POST', `/api/nodes/${nodeId}/deploy`, {});
must(res.status === 202, `deploy ${label}`, res.text);
}
for (const label of ['entry', 'exit']) {
const drained = await drainAgentJobs(client, agentKeys[label]);
steps.push({ name: `drain-${label}`, jobs: drained.map(({ job, complete }) => ({ job, complete })) });
log(`[seed] drained ${drained.length} job(s) on ${label} agent`);
}
const nodesRes = await admin('list-nodes-after-deploy', 'GET', '/api/nodes');
for (const [label, nodeId] of [['N1', ids.n1], ['N2', ids.n2]]) {
const node = nodesRes.json.nodes.find((item) => item.id === nodeId);
must(node && node.status === 'active', `node ${label} active after deploy`,
`got ${node && node.status}`);
}
log('[seed] N1 and N2 are active');
// --- Alice subscription expectations (pre-usage state) -------------------
aliceSubs = await fetchAllSubscriptions(client, aliceToken);
for (const [key, sub] of Object.entries(aliceSubs)) {
must(sub.status === 200, `Alice subscription ${key}`, `status ${sub.status} body ${sub.body.slice(0, 120)}`);
}
steps.push({ name: 'alice-subscriptions', cases: Object.keys(aliceSubs) });
// --- usage + observations --------------------------------------------------
const usage = await client.agentPost('/api/agent/usage', agentKeys.entry, {
epoch: SEED_USAGE.epoch,
samples: [
{ kind: 'user', id: ids.alice, uplink: SEED_USAGE.user.uplink, downlink: SEED_USAGE.user.downlink },
{ kind: 'inbound', id: ids.n1, uplink: SEED_USAGE.inbound.uplink, downlink: SEED_USAGE.inbound.downlink },
],
});
record('usage-report', { method: 'POST', pathname: '/api/agent/usage' }, usage);
must(usage.status === 200, 'usage report', usage.text);
const customersRes = await admin('list-customers-after-usage', 'GET', '/api/customers');
const aliceAfter = customersRes.json.customers.find((item) => item.id === ids.alice);
must(aliceAfter && aliceAfter.usedBytes === SEED_USAGE.user.uplink + SEED_USAGE.user.downlink,
'Alice usedBytes after usage report', `got ${aliceAfter && aliceAfter.usedBytes}`);
const observations = await client.agentPost('/api/agent/observations', agentKeys.entry, {
observations: [{ customerId: ids.alice, ip: SEED_USAGE.aliceIp }],
});
record('observations-report', { method: 'POST', pathname: '/api/agent/observations' }, observations);
must(observations.status === 200, 'observations report', observations.text);
log(`[seed] usage recorded (Alice usedBytes=${aliceAfter.usedBytes}), observation ${SEED_USAGE.aliceIp} recorded`);
} finally {
await stopPanel(proc);
}
// --- export ---------------------------------------------------------------
fs.mkdirSync(outDir, { recursive: true });
const seedPath = path.join(outDir, 'seed.json');
const db = JSON.parse(fs.readFileSync(dataFile, 'utf8'));
must(db.keyStore && Object.keys(db.keyStore).length === 1, 'seed keyStore holds exactly the N1 Reality key',
`got ${db.keyStore ? Object.keys(db.keyStore).length : 'none'}`);
fs.copyFileSync(dataFile, seedPath);
const expectPath = path.join(outDir, 'seed-expect.json');
fs.writeFileSync(expectPath, `${JSON.stringify({ alice: aliceSubs }, null, 2)}\n`);
const logPath = path.join(outDir, 'seed-log.json');
fs.writeFileSync(logPath, `${JSON.stringify({
meta: {
generatedAt: new Date().toISOString(),
adminUsername: ADMIN_USERNAME,
adminPassword: ADMIN_PASSWORD,
ids,
agentKeys: { entry: agentKeys.entry, exit: agentKeys.exit },
seedUsage: SEED_USAGE,
},
steps,
}, null, 2)}\n`);
fs.rmSync(tmpDir, { recursive: true, force: true });
log(`[seed] wrote ${seedPath}`);
log(`[seed] wrote ${expectPath}`);
log(`[seed] wrote ${logPath}`);
return { seedPath, expectPath, logPath, ids };
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
const args = process.argv.slice(2);
let outDir = DIFFTEST_DIR;
for (let i = 0; i < args.length; i += 1) {
if (args[i] === '--out-dir' && args[i + 1]) { outDir = path.resolve(args[i + 1]); i += 1; }
}
await generateSeed({ outDir });
}