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,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