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
+256
View File
@@ -0,0 +1,256 @@
// Golden-seed generator for the NexusGate panel differential tests.
//
// Starts the NODE panel on a random high port with a throwaway data file
// (NG_DATA_FILE inside an fs.mkdtemp directory — the repository data/ dir is
// never touched) and NG_ADMIN_PASSWORD=diff-admin-pass, then drives the full
// business flow through the public HTTP APIs only:
//
// login
// -> create entry server (entry.example.com / v6.example.com /
// tls.example.com, ports 20000-50000) and exit server (exit.example.com)
// -> enrollment-token + simulated agent enroll for both servers
// -> customers Alice (unlimited) and Bob (1 GiB, ipLimit 5, deviceLimit 2)
// -> nodes N1 (forward, vless-reality-vision, exit
// shadowsocks-2022-aes256, realityServerName www.amd.com) and
// N2 (direct, shadowsocks-2022-aes128)
// -> assign N1: Alice+Bob, N2: Alice; deploy both
// -> agents poll and complete apply jobs (Reality public key reported
// from payload.keyMaterial, like the real agent)
// -> assert both nodes active
// -> capture Alice's subscriptions in every format as expectations
// -> report usage (user sample for Alice, inbound sample for N1)
// and one observation (one IP for Alice)
// -> export the panel DB as seed.json
//
// Outputs (in --out-dir, default: this directory):
// seed.json the exported panel database (the golden seed)
// seed-log.json every request/response of the run + recovered agentKeys
// (run.mjs needs the raw seed agentKeys to keep driving
// the seed agents in phase 2; they only exist in the
// enroll responses, the DB stores hashes)
// seed-expect.json Alice's subscription responses before the usage report
// (bodies stored in comparison form: base64 cases are
// stored decoded, matching fetchAllSubscriptions in
// lib.mjs, so run.mjs can compare them directly)
//
// Usage: node seed.mjs [--out-dir <dir>]
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
ADMIN_PASSWORD, ADMIN_USERNAME, PanelClient, DIFFTEST_DIR,
drainAgentJobs, fetchAllSubscriptions, freePort, makeTempDir,
startPanel, stopPanel, waitHealth,
} from './lib.mjs';
// Deterministic seed traffic numbers. After the usage report Alice must
// have usedBytes = 1000 + 2000 = 3000 and N1 meteredTraffic 4000/5000.
export const SEED_USAGE = {
epoch: 'diff-epoch-1',
user: { uplink: 1000, downlink: 2000 },
inbound: { uplink: 4000, downlink: 5000 },
aliceIp: '203.0.113.10',
};
export async function generateSeed({ outDir = DIFFTEST_DIR, quiet = false } = {}) {
const log = (...args) => { if (!quiet) console.log(...args); };
const tmpDir = makeTempDir('nexusgate-seed-');
const dataFile = path.join(tmpDir, 'nexusgate.json');
const port = await freePort();
const proc = startPanel({ kind: 'node', dataFile, port });
const client = new PanelClient(proc.base);
const steps = [];
const ids = {};
const agentKeys = {};
let aliceSubs = null;
const record = (name, request, res) => {
steps.push({
name,
request,
response: { status: res.status, body: res.json !== null && res.json !== undefined ? res.json : res.text },
});
};
const must = (cond, what, extra = '') => {
if (!cond) throw new Error(`seed step failed: ${what} ${extra}`);
};
const admin = async (name, method, pathname, body) => {
const res = await client.admin(method, pathname, body);
record(name, { method, pathname, body }, res);
return res;
};
try {
await waitHealth(proc);
log(`[seed] node panel on ${proc.base} (data ${dataFile})`);
// --- login -------------------------------------------------------------
const login = await client.login();
record('login', { method: 'POST', pathname: '/api/auth/login', body: { username: ADMIN_USERNAME } }, login);
must(login.status === 200, 'login', login.text);
// --- servers -----------------------------------------------------------
const entryRes = await admin('create-server-entry', 'POST', '/api/servers', {
name: 'Entry', role: 'relay', region: '',
publicAddress: 'entry.example.com', publicAddressV6: 'v6.example.com',
tlsDomain: 'tls.example.com', portRangeStart: 20000, portRangeEnd: 50000,
});
must(entryRes.status === 201, 'create entry server', entryRes.text);
ids.entryServer = entryRes.json.server.id;
const exitRes = await admin('create-server-exit', 'POST', '/api/servers', {
name: 'Exit', role: 'exit', region: '',
publicAddress: 'exit.example.com', publicAddressV6: '',
tlsDomain: '', portRangeStart: 20000, portRangeEnd: 50000,
});
must(exitRes.status === 201, 'create exit server', exitRes.text);
ids.exitServer = exitRes.json.server.id;
// --- enroll both agents --------------------------------------------------
for (const [label, serverId] of [['entry', ids.entryServer], ['exit', ids.exitServer]]) {
const ticket = await admin(`enrollment-token-${label}`, 'POST', `/api/servers/${serverId}/enrollment-token`, {});
must(ticket.status === 201, `enrollment token (${label})`, ticket.text);
const enrolled = await client.request('POST', '/api/agent/enroll', {
body: { token: ticket.json.token, hostname: `diff-agent-${label}`, version: '0.6.8', system: {} },
cookie: null,
});
record(`enroll-${label}`, { method: 'POST', pathname: '/api/agent/enroll' }, enrolled);
must(enrolled.status === 201, `enroll (${label})`, enrolled.text);
agentKeys[label] = enrolled.json.agentKey;
ids[`${label}Agent`] = enrolled.json.agentId;
const hb = await client.agentPost('/api/agent/heartbeat', agentKeys[label], { version: '0.6.8', system: {} });
record(`heartbeat-${label}`, { method: 'POST', pathname: '/api/agent/heartbeat' }, hb);
must(hb.status === 200, `heartbeat (${label})`, hb.text);
}
// --- customers -----------------------------------------------------------
const aliceRes = await admin('create-customer-alice', 'POST', '/api/customers', { name: 'Alice' });
must(aliceRes.status === 201, 'create Alice', aliceRes.text);
ids.alice = aliceRes.json.customer.id;
const aliceToken = aliceRes.json.customer.subscriptionToken;
const bobRes = await admin('create-customer-bob', 'POST', '/api/customers', {
name: 'Bob', trafficLimitBytes: 1073741824, ipLimit: 5, deviceLimit: 2,
});
must(bobRes.status === 201, 'create Bob', bobRes.text);
ids.bob = bobRes.json.customer.id;
// --- nodes ---------------------------------------------------------------
const n1Res = await admin('create-node-n1', 'POST', '/api/nodes', {
name: 'N1', serverId: ids.entryServer, topology: 'forward',
protocol: 'vless-reality-vision', exitServerId: ids.exitServer,
exitProtocol: 'shadowsocks-2022-aes256', realityServerName: 'www.amd.com',
});
must(n1Res.status === 201, 'create N1', n1Res.text);
ids.n1 = n1Res.json.node.id;
const n2Res = await admin('create-node-n2', 'POST', '/api/nodes', {
name: 'N2', serverId: ids.entryServer, topology: 'direct',
protocol: 'shadowsocks-2022-aes128',
});
must(n2Res.status === 201, 'create N2', n2Res.text);
ids.n2 = n2Res.json.node.id;
// --- assignments ---------------------------------------------------------
for (const [nodeLabel, nodeId, customerLabel, customerId] of [
['n1', ids.n1, 'alice', ids.alice],
['n1', ids.n1, 'bob', ids.bob],
['n2', ids.n2, 'alice', ids.alice],
]) {
const res = await admin(`assign-${nodeLabel}-${customerLabel}`, 'POST', `/api/nodes/${nodeId}/assign`, { customerId });
must(res.status === 200, `assign ${nodeLabel}/${customerLabel}`, res.text);
}
// --- deploy + drain ------------------------------------------------------
for (const [label, nodeId] of [['n1', ids.n1], ['n2', ids.n2]]) {
const res = await admin(`deploy-${label}`, 'POST', `/api/nodes/${nodeId}/deploy`, {});
must(res.status === 202, `deploy ${label}`, res.text);
}
for (const label of ['entry', 'exit']) {
const drained = await drainAgentJobs(client, agentKeys[label]);
steps.push({ name: `drain-${label}`, jobs: drained.map(({ job, complete }) => ({ job, complete })) });
log(`[seed] drained ${drained.length} job(s) on ${label} agent`);
}
const nodesRes = await admin('list-nodes-after-deploy', 'GET', '/api/nodes');
for (const [label, nodeId] of [['N1', ids.n1], ['N2', ids.n2]]) {
const node = nodesRes.json.nodes.find((item) => item.id === nodeId);
must(node && node.status === 'active', `node ${label} active after deploy`,
`got ${node && node.status}`);
}
log('[seed] N1 and N2 are active');
// --- Alice subscription expectations (pre-usage state) -------------------
aliceSubs = await fetchAllSubscriptions(client, aliceToken);
for (const [key, sub] of Object.entries(aliceSubs)) {
must(sub.status === 200, `Alice subscription ${key}`, `status ${sub.status} body ${sub.body.slice(0, 120)}`);
}
steps.push({ name: 'alice-subscriptions', cases: Object.keys(aliceSubs) });
// --- usage + observations --------------------------------------------------
const usage = await client.agentPost('/api/agent/usage', agentKeys.entry, {
epoch: SEED_USAGE.epoch,
samples: [
{ kind: 'user', id: ids.alice, uplink: SEED_USAGE.user.uplink, downlink: SEED_USAGE.user.downlink },
{ kind: 'inbound', id: ids.n1, uplink: SEED_USAGE.inbound.uplink, downlink: SEED_USAGE.inbound.downlink },
],
});
record('usage-report', { method: 'POST', pathname: '/api/agent/usage' }, usage);
must(usage.status === 200, 'usage report', usage.text);
const customersRes = await admin('list-customers-after-usage', 'GET', '/api/customers');
const aliceAfter = customersRes.json.customers.find((item) => item.id === ids.alice);
must(aliceAfter && aliceAfter.usedBytes === SEED_USAGE.user.uplink + SEED_USAGE.user.downlink,
'Alice usedBytes after usage report', `got ${aliceAfter && aliceAfter.usedBytes}`);
const observations = await client.agentPost('/api/agent/observations', agentKeys.entry, {
observations: [{ customerId: ids.alice, ip: SEED_USAGE.aliceIp }],
});
record('observations-report', { method: 'POST', pathname: '/api/agent/observations' }, observations);
must(observations.status === 200, 'observations report', observations.text);
log(`[seed] usage recorded (Alice usedBytes=${aliceAfter.usedBytes}), observation ${SEED_USAGE.aliceIp} recorded`);
} finally {
await stopPanel(proc);
}
// --- export ---------------------------------------------------------------
fs.mkdirSync(outDir, { recursive: true });
const seedPath = path.join(outDir, 'seed.json');
const db = JSON.parse(fs.readFileSync(dataFile, 'utf8'));
must(db.keyStore && Object.keys(db.keyStore).length === 1, 'seed keyStore holds exactly the N1 Reality key',
`got ${db.keyStore ? Object.keys(db.keyStore).length : 'none'}`);
fs.copyFileSync(dataFile, seedPath);
const expectPath = path.join(outDir, 'seed-expect.json');
fs.writeFileSync(expectPath, `${JSON.stringify({ alice: aliceSubs }, null, 2)}\n`);
const logPath = path.join(outDir, 'seed-log.json');
fs.writeFileSync(logPath, `${JSON.stringify({
meta: {
generatedAt: new Date().toISOString(),
adminUsername: ADMIN_USERNAME,
adminPassword: ADMIN_PASSWORD,
ids,
agentKeys: { entry: agentKeys.entry, exit: agentKeys.exit },
seedUsage: SEED_USAGE,
},
steps,
}, null, 2)}\n`);
fs.rmSync(tmpDir, { recursive: true, force: true });
log(`[seed] wrote ${seedPath}`);
log(`[seed] wrote ${expectPath}`);
log(`[seed] wrote ${logPath}`);
return { seedPath, expectPath, logPath, ids };
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
const args = process.argv.slice(2);
let outDir = DIFFTEST_DIR;
for (let i = 0; i < args.length; i += 1) {
if (args[i] === '--out-dir' && args[i + 1]) { outDir = path.resolve(args[i + 1]); i += 1; }
}
await generateSeed({ outDir });
}