// NexusGate Go panel golden-behavior harness. // // Freezes the CURRENT Go panel behavior as a regression baseline now that the // Node implementation is retired (2026-10-21 decision): a single Go panel is // started on a copy of the difftest seed DB, driven through a fixed script of // API calls, and every response is passed through the difftest learning // Normalizer (volatile ids / secrets / ports / timestamps become placeholders) // before being stored in golden.json. `verify` replays the same script and // diffs against the frozen file; any diff must be either an intended change // (re-capture and note it in the commit) or a regression. // // Usage: // node golden.mjs capture [--bin /path/to/nexusgate-panel] // node golden.mjs verify [--bin /path/to/nexusgate-panel] [--golden golden.json] // // The binary defaults to ../nexusgate-panel-linux-amd64 (same resolution as // the difftest runner). The repo data/ directory is never touched. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { startPanel, waitHealth, stopPanel, makeTempDir, freePort, PanelClient, Normalizer, firstDiff, fetchAllSubscriptions, drainAgentJobs, pollOnce, completeJob, DEFAULT_GO_BIN, DIFFTEST_DIR, } from '../difftest/lib.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const SEED = path.join(DIFFTEST_DIR, 'seed.json'); const SEED_LOG = path.join(DIFFTEST_DIR, 'seed-log.json'); function parseArgs(argv) { const args = { mode: argv[2], bin: process.env.GO_PANEL_BIN || DEFAULT_GO_BIN, golden: path.join(HERE, 'golden.json') }; for (let i = 3; i < argv.length; i += 1) { if (argv[i] === '--bin') args.bin = argv[++i]; else if (argv[i] === '--golden') args.golden = argv[++i]; else throw new Error(`unknown argument: ${argv[i]}`); } if (!['capture', 'verify'].includes(args.mode)) throw new Error('mode must be capture or verify'); return args; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // View of an admin/agent HTTP response, mirroring difftest's view(). function view(res) { return { status: res.status, body: res.json !== null && res.json !== undefined ? res.json : res.text }; } // Keys whose numeric values are wall-clock derived and therefore not part of // the behavior contract; squashed wherever they appear. const VOLATILE_NUM_KEYS = /uptime|serverTimeMs|nowMs/i; function squashVolatile(value, keyName = null) { // A resource's inbound `listen` is the panel host's own interface address // (bind resolution) — environment-specific, not behavior. if (keyName === 'listen' && typeof value === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return ''; if (Array.isArray(value)) return value.map((v) => squashVolatile(v, keyName)); if (value && typeof value === 'object') { const out = {}; for (const [k, v] of Object.entries(value)) { out[k] = typeof v === 'number' && VOLATILE_NUM_KEYS.test(k) ? '' : squashVolatile(v, k); } return out; } return value; } // /api/system: almost every number is a live sample. Only structural counts // that follow deterministically from the script are kept verbatim. const SYSTEM_KEEP_NUM = new Set(['nodesTotal', 'customersTotal', 'onlineNodes', 'onlineCustomers', 'connections']); function squashSystem(value, keyName = null) { if (typeof value === 'number') return keyName && SYSTEM_KEEP_NUM.has(keyName) ? value : ''; if (typeof value === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return ''; if (Array.isArray(value)) return value.map((v) => squashSystem(v, keyName)); if (value && typeof value === 'object') { const out = {}; for (const k of Object.keys(value).sort()) out[k] = squashSystem(value[k], k); return out; } return value; } async function runScript(bin) { const seedLog = JSON.parse(fs.readFileSync(SEED_LOG, 'utf8')); const seedIds = seedLog.meta.ids; const tmp = makeTempDir('nexusgate-golden-'); const dataFile = path.join(tmp, 'nexusgate.json'); fs.copyFileSync(SEED, dataFile); const port = await freePort(); const proc = startPanel({ kind: 'go', bin, dataFile, port }); const N = new Normalizer(); const sections = {}; const cap = (name, value) => { sections[name] = N.process(squashVolatile(structuredClone(value))); }; try { await waitHealth(proc); const client = new PanelClient(proc.base); const ids = { entryServer: seedIds.entryServer, exitServer: seedIds.exitServer, n1: seedIds.n1, n2: seedIds.n2, alice: seedIds.alice, bob: seedIds.bob }; const agentKeys = { entry: seedLog.meta.agentKeys.entry, exit: seedLog.meta.agentKeys.exit }; const subTokens = {}; cap('login', view(await client.login())); cap('session', view(await client.admin('GET', '/api/session'))); // Old-shape heartbeats (no engine object at all): the panel must keep // accepting them and simply have no engine state to show. cap('heartbeatSeedAgents', { entry: view(await client.agentPost('/api/agent/heartbeat', agentKeys.entry, { version: '0.6.8', system: {} })), exit: view(await client.agentPost('/api/agent/heartbeat', agentKeys.exit, { version: '0.6.8', system: {} })), }); const customersRes = await client.admin('GET', '/api/customers'); for (const c of customersRes.json.customers) subTokens[c.name.toLowerCase()] = c.subscriptionToken; const nodesRes = await client.admin('GET', '/api/nodes'); cap('lists', { overview: view(await client.admin('GET', '/api/overview')), servers: view(await client.admin('GET', '/api/servers')), customers: view(customersRes), nodes: view(nodesRes), jobs: view(await client.admin('GET', '/api/jobs')), }); // New-shape heartbeat carrying the core version (round-2 item 4): the // panel must persist it on the server's engine state and expose it in // the servers list. The seed-shape heartbeats above (no engine object) // must keep working and leave engine state absent. cap('heartbeatWithEngine', view(await client.agentPost('/api/agent/heartbeat', agentKeys.entry, { version: '0.6.8', system: {}, engine: { status: 'ready', detail: '', singBoxInstalled: true, certificates: [], singBoxStatus: 'ready', singBoxVersion: '1.13.21' }, }))); cap('serversAfterEngineHeartbeat', view(await client.admin('GET', '/api/servers'))); cap('credentials', { alice: view(await client.admin('GET', `/api/customers/${ids.alice}/credentials`)), bob: view(await client.admin('GET', `/api/customers/${ids.bob}/credentials`)), }); cap('subscriptionsSeed', { alice: await fetchAllSubscriptions(client, subTokens.alice), bob: await fetchAllSubscriptions(client, subTokens.bob), }); // New server + agent enrolment. const s3res = await client.admin('POST', '/api/servers', { name: 'S3', role: 'relay', region: '', publicAddress: 's3.example.com', publicAddressV6: '', tlsDomain: '', portRangeStart: 20000, portRangeEnd: 50000, }); ids.s3 = s3res.json.server.id; cap('createServerS3', view(s3res)); const ticket = await client.admin('POST', `/api/servers/${ids.s3}/enrollment-token`, {}); const enrolled = await client.request('POST', '/api/agent/enroll', { body: { token: ticket.json.token, hostname: 'golden-agent-s3', version: '0.6.8', system: {} }, cookie: null, }); agentKeys.s3 = enrolled.json.agentKey; cap('enrollS3', { ticket: view(ticket), enroll: view(enrolled) }); const carolRes = await client.admin('POST', '/api/customers', { name: 'Carol' }); ids.carol = carolRes.json.customer.id; subTokens.carol = carolRes.json.customer.subscriptionToken; cap('createCarol', view(carolRes)); const n3res = await client.admin('POST', '/api/nodes', { name: 'N3', serverId: ids.s3, topology: 'direct', protocol: 'vless-reality', realityServerName: 'www.amd.com', }); ids.n3 = n3res.json.node.id; cap('createNodeN3', view(n3res)); cap('assignN3', view(await client.admin('POST', `/api/nodes/${ids.n3}/assign`, { customerId: ids.carol }))); cap('deployN3', view(await client.admin('POST', `/api/nodes/${ids.n3}/deploy`, {}))); const drainedS3 = await drainAgentJobs(client, agentKeys.s3); cap('deployN3Jobs', drainedS3.map(({ job, complete }) => ({ job, complete }))); cap('nodesAfterDeploy', view(await client.admin('GET', '/api/nodes'))); cap('carolSubscriptions', await fetchAllSubscriptions(client, subTokens.carol)); // Usage + presence/speed projection (two cumulative reports 6s apart). const usageBody = (u1, d1, u2, d2) => ({ epoch: 'golden-epoch-1', samples: [ { kind: 'user', id: ids.alice, uplink: u1, downlink: d1 }, { kind: 'inbound', id: ids.n1, uplink: u2, downlink: d2 }, ], }); cap('usageReport1', view(await client.agentPost('/api/agent/usage', agentKeys.entry, usageBody(1500, 2100, 5000, 6000)))); await sleep(6000); cap('usageReport2', view(await client.agentPost('/api/agent/usage', agentKeys.entry, usageBody(4500, 6300, 8000, 9000)))); const customers2 = await client.admin('GET', '/api/customers'); const nodes2 = await client.admin('GET', '/api/nodes'); const nameOf = (id) => { const c = customers2.json.customers.find((x) => x.id === id); return c ? c.name : '?'; }; sections.presenceProjection = { customers: customers2.json.customers.map((c) => ({ name: c.name, online: c.online, speedPositive: (c.speedUpBps || 0) > 0 && (c.speedDownBps || 0) > 0, hasLastActiveAt: Boolean(c.lastActiveAt), })), nodes: nodes2.json.nodes.map((n) => ({ name: n.name, clientCount: n.clientCount, onlineCount: n.onlineCount, onlineNames: (n.onlineCustomerIds || []).map(nameOf), speedPositive: (n.speedUpBps || 0) > 0, })), }; cap('customersAfterUsage', view(customers2)); // UI-style full-payload PATCH of the seed forward node N1 (name change // only). The browser form always submits portMode + port(null in random // mode) + serverId; this is the exact traffic shape behind the port // re-roll bug, frozen here so the fix shows up as a deliberate diff. const n1 = nodes2.json.nodes.find((n) => n.id === ids.n1); const uiPatch = { name: 'N1-renamed', serverId: n1.serverId, protocol: n1.protocol, portMode: n1.portMode, port: n1.portMode === 'fixed' ? n1.port : null, networkMode: n1.networkMode, topology: n1.topology, exitServerId: n1.exitServerId, exitProtocol: n1.exitProtocol, exitPortMode: n1.exitPortMode, exitPort: n1.exitPortMode === 'fixed' ? n1.exitPort : null, realityServerName: n1.realityServerName || '', realityDestPort: n1.realityDestPort || null, }; cap('nodePatchUiStyle', view(await client.admin('PATCH', `/api/nodes/${ids.n1}`, uiPatch))); const drainedEntry = await drainAgentJobs(client, agentKeys.entry); const drainedExit = await drainAgentJobs(client, agentKeys.exit); cap('nodePatchJobs', { entry: drainedEntry.map(({ job, complete }) => ({ job, complete })), exit: drainedExit.map(({ job, complete }) => ({ job, complete })), }); cap('nodesAfterPatch', view(await client.admin('GET', '/api/nodes'))); const systemRes = await client.admin('GET', '/api/system'); sections.systemShape = { status: systemRes.status, body: squashSystem(N.process(structuredClone(systemRes.json))) }; cap('backup', view(await client.admin('GET', '/api/backup'))); const anon = new PanelClient(proc.base); cap('errors', { unauthenticated: view(await anon.request('GET', '/api/customers')), loginFailure: view(await anon.request('POST', '/api/auth/login', { body: { username: 'admin', password: 'wrong-password' }, cookie: null })), missingNode: view(await client.admin('GET', '/api/nodes/nod_00000000-0000-0000-0000-000000000000')), badSubscription: await (async () => { const r = await client.getSubscription('nosuchtoken000000', 'raw', 'NexusGate-Golden/1.0'); return { status: r.status, body: r.body }; })(), }); // Round-2 item 8: manual landing endpoint. Creation is exit-ready with // no exit server; deploying queues only the entry-side job, whose // outbound targets the manual address with the operator's credentials. const manualCreate = await client.admin('POST', '/api/nodes', { name: 'N4-Manual', serverId: ids.entryServer, protocol: 'vless-reality-vision', topology: 'forward', exitMode: 'manual', exitAddress: '203.0.113.9', exitProtocol: 'shadowsocks-2022-aes128', exitPort: 23464, exitPortMode: 'fixed', exitCredentials: { password: 'FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA==' }, portMode: 'fixed', port: 23466, networkMode: 'ipv4', realityServerName: 'www.amd.com', realityDestPort: 443, }); cap('manualCreate', view(manualCreate)); const manualNodeId = manualCreate.json.node.id; cap('manualAssign', view(await client.admin('POST', `/api/nodes/${manualNodeId}/assign`, { customerId: ids.carol }))); cap('manualDeploy', view(await client.admin('POST', `/api/nodes/${manualNodeId}/deploy`, {}))); const drainedManual = await drainAgentJobs(client, agentKeys.entry); cap('manualJobs', drainedManual.map(({ job, complete }) => ({ job, complete }))); cap('manualNodes', view(await client.admin('GET', '/api/nodes'))); return sections; } finally { await stopPanel(proc); fs.rmSync(tmp, { recursive: true, force: true }); } } const args = parseArgs(process.argv); if (!fs.existsSync(args.bin)) { console.error(`Go panel binary not found: ${args.bin}`); process.exit(2); } const sections = await runScript(args.bin); if (args.mode === 'capture') { const payload = { meta: { capturedAt: new Date().toISOString(), note: 'Go panel golden baseline; see README.md in this directory.' }, sections }; fs.writeFileSync(args.golden, `${JSON.stringify(payload, null, 2)}\n`); console.log(`CAPTURED ${Object.keys(sections).length} sections -> ${args.golden}`); } else { const golden = JSON.parse(fs.readFileSync(args.golden, 'utf8')); const diff = firstDiff(golden.sections, sections); if (!diff) { console.log('GOLDEN PASS — behavior identical to frozen baseline'); process.exit(0); } console.log(`GOLDEN DIFF at ${diff.at}\n golden: ${diff.a}\n actual: ${diff.b}`); process.exit(1); }