// 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 ] 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 }); }