A forward node's exit target is now a choice: a managed panel server (existing behavior) or a manual landing endpoint — address + port + protocol (VLESS / SS2022 / SOCKS5) + operator-supplied credentials, including third-party nodes the panel does not run. The panel only builds the entry-side sing-box outbound and route rule; there is no exit resource, no exit job, and the node is exit-ready by definition. - model: Node.ExitMode/ExitAddress; manual exits force fixed port mode and skip the exit-server port table entirely - validation: address/port required; per-protocol credential presence; SS2022 passwords must be base64 key material of the method's key length (one PSK segment or serverKey:userKey) — the exact form sing-box accepts, rejected up front instead of failing at deploy - orchestration: resource building, deploy validation and rebuilds all branch on manualExit; switching managed->manual queues removal of the old exit resource, manual->managed mints a fresh exit resource id (deploy validation previously dereferenced the nil exit server — regression test locks the fix) - UI: exit target segmented control, link paste box that parses vless:// / ss:// / socks5:// links into the form, per-protocol credential fields (blank keeps the stored value on edit) - golden: new manualCreate/manualAssign/manualDeploy/manualJobs/ manualNodes sections — exactly one entry-side job whose outbound targets the manual address; all prior sections unchanged - the generated combined sing-box config passes the real sing-box 1.13.21 check
279 lines
14 KiB
JavaScript
279 lines
14 KiB
JavaScript
// 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 '<hostip>';
|
|
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) ? '<num>' : 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 : '<num>';
|
|
if (typeof value === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return '<ip>';
|
|
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);
|
|
}
|