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 分钟)污染后续步。
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user