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
+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 };