Files
NexusGate/test/reality-keys.test.js

169 lines
8.8 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { Store } = require('../lib/store');
const { Orchestrator, rebuildNode } = require('../lib/orchestrator');
const { generateRealityKeyPair, ensureRealityKey } = require('../lib/protocols');
const B64URL_43 = /^[A-Za-z0-9_-]{43}$/;
test('generateRealityKeyPair produces consistent 43-char base64url x25519 keys', () => {
const pair = generateRealityKeyPair();
assert.match(pair.privateKey, B64URL_43);
assert.match(pair.publicKey, B64URL_43);
// Deriving the public key from the private one must reproduce the pair:
// these are real x25519 keys in the exact encoding sing-box expects.
const priv = crypto.createPrivateKey({ key: { kty: 'OKP', crv: 'X25519', d: pair.privateKey, x: pair.publicKey }, format: 'jwk' });
const derived = crypto.createPublicKey(priv).export({ format: 'jwk' });
assert.equal(derived.x, pair.publicKey);
assert.notEqual(generateRealityKeyPair().privateKey, pair.privateKey);
});
test('ensureRealityKey generates once, reuses verbatim, ignores non-Reality nodes', () => {
const data = { keyStore: {} };
const node = { id: 'nod_x', protocol: 'vless-reality', topology: 'direct', realityServerName: 'www.tesla.com' };
const first = ensureRealityKey(data, node);
assert.equal(node.reality.keyId, 'reality-nod_x');
assert.match(first.privateKey, B64URL_43);
assert.match(first.publicKey, B64URL_43);
assert.strictEqual(ensureRealityKey(data, node), first);
assert.equal(ensureRealityKey(data, { id: 'nod_y', protocol: 'shadowsocks-2022-aes128', topology: 'direct' }), null);
assert.deepEqual(Object.keys(data.keyStore), ['reality-nod_x']);
});
async function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-keys-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const store = await new Store(path.join(dir, 'data.json')).init();
assert.deepEqual(store.data.keyStore, {});
const stamp = new Date().toISOString();
await store.transaction((data) => {
data.servers.push({ id: 'srv_key', name: '入口', publicAddress: '203.0.113.10', portRangeStart: 20000, portRangeEnd: 50000, status: 'online', lastSeenAt: stamp });
data.customers.push({ id: 'cus_key', name: '客户', status: 'active' });
data.nodes.push({
id: 'nod_key', serverId: 'srv_key', name: 'Reality 节点', protocol: 'vless-reality-vision',
topology: 'direct', networkMode: 'ipv4', port: 23456, resourceId: 'res_nod_key_1',
status: 'draft', realityServerName: 'www.tesla.com', createdAt: stamp, updatedAt: stamp,
});
data.nodeClients.push({
id: 'ncl_key', nodeId: 'nod_key', customerId: 'cus_key', status: 'active',
credentials: { clientId: crypto.randomUUID(), relayPassword: 'secret', shortId: 'abcd1234' },
createdAt: stamp, updatedAt: stamp,
});
});
return { store, orchestrator: new Orchestrator(store) };
}
function applyJobs(store) {
return store.data.jobs.filter((job) => job.action === 'apply_resource' && job.payload && job.payload.kind === 'node');
}
test('deploy payload carries the panel-hosted keyMaterial and reuses it verbatim', async (t) => {
const { store } = await fixture(t);
await store.transaction(async (data) => { await rebuildNode(data, data.nodes[0]); });
const first = applyJobs(store).at(-1);
assert.ok(first, 'an apply job was queued');
// The resource itself keeps the placeholder; the key travels as keyMaterial.
assert.match(JSON.stringify(first.payload.resource), /\$\{REALITY_PRIVATE:reality-nod_key\}/);
const material = first.payload.keyMaterial;
assert.deepEqual(Object.keys(material), ['reality-nod_key']);
assert.match(material['reality-nod_key'].privateKey, B64URL_43);
assert.match(material['reality-nod_key'].publicKey, B64URL_43);
assert.equal(store.data.keyStore['reality-nod_key'].privateKey, material['reality-nod_key'].privateKey);
assert.equal(store.data.keyStore['reality-nod_key'].publicKey, material['reality-nod_key'].publicKey);
// A redeploy ships the identical keypair: the publicKey is stable.
await store.transaction(async (data) => { await rebuildNode(data, data.nodes[0]); });
const second = applyJobs(store).at(-1);
assert.deepEqual(second.payload.keyMaterial, material);
});
test('agent backfill fills a keyStore gap and is stripped from node artifacts', async (t) => {
const { store, orchestrator } = await fixture(t);
const agentPair = generateRealityKeyPair();
await store.transaction((data) => {
const node = data.nodes[0];
node.status = 'deploying';
node.reality = { keyId: 'reality-nod_key', serverName: 'www.tesla.com', destPort: 443 };
data.jobs.push({ id: 'job_bf', serverId: 'srv_key', nodeId: 'nod_key', action: 'apply_resource',
payload: { kind: 'node' }, status: 'running', attempts: 1, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
});
await orchestrator.completeJob('job_bf', true, {
artifacts: { realityPublicKey: agentPair.publicKey, keyMaterial: { 'reality-nod_key': agentPair } },
});
const stored = store.data.keyStore['reality-nod_key'];
assert.equal(stored.privateKey, agentPair.privateKey);
assert.equal(stored.publicKey, agentPair.publicKey);
const node = store.data.nodes[0];
assert.equal(node.artifacts.realityPublicKey, agentPair.publicKey);
assert.equal(node.artifacts.keyMaterial, undefined);
});
test('agent backfill never overwrites a panel-hosted key', async (t) => {
const { store, orchestrator } = await fixture(t);
await store.transaction((data) => {
ensureRealityKey(data, data.nodes[0]);
data.nodes[0].status = 'deploying';
data.jobs.push({ id: 'job_cf', serverId: 'srv_key', nodeId: 'nod_key', action: 'apply_resource',
payload: { kind: 'node' }, status: 'running', attempts: 1, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
});
const panelPair = store.data.keyStore['reality-nod_key'];
const agentPair = generateRealityKeyPair();
assert.notEqual(agentPair.publicKey, panelPair.publicKey);
await orchestrator.completeJob('job_cf', true, {
artifacts: { realityPublicKey: agentPair.publicKey, keyMaterial: { 'reality-nod_key': agentPair } },
});
const stored = store.data.keyStore['reality-nod_key'];
assert.equal(stored.privateKey, panelPair.privateKey);
assert.equal(stored.publicKey, panelPair.publicKey);
// Subscription-facing publicKey is the panel's, not the agent's divergent one.
assert.equal(store.data.nodes[0].artifacts.realityPublicKey, panelPair.publicKey);
assert.ok(store.data.activity.some((event) => event.action === 'reality_key_conflict'));
});
test('node deletion drops the hosted key (draft delete and confirmed delete)', async (t) => {
const { store, orchestrator } = await fixture(t);
const stamp = new Date().toISOString();
await store.transaction((data) => {
ensureRealityKey(data, data.nodes[0]);
// Second node mid-deletion: record removed when the agent confirms.
data.nodes.push({
id: 'nod_key2', serverId: 'srv_key', name: '待删节点', protocol: 'vless-reality',
topology: 'direct', networkMode: 'ipv4', port: 23457, resourceId: 'res_nod_key_2',
status: 'removing', pendingDelete: true, entryReady: true,
reality: { keyId: 'reality-nod_key2', serverName: 'www.tesla.com', destPort: 443 },
createdAt: stamp, updatedAt: stamp,
});
data.keyStore['reality-nod_key2'] = { ...generateRealityKeyPair(), updatedAt: stamp };
data.jobs.push({ id: 'job_del', serverId: 'srv_key', nodeId: 'nod_key2', action: 'delete_resource',
payload: { resourceId: 'res_nod_key_2', kind: 'node' }, status: 'running', attempts: 1, createdAt: stamp, updatedAt: stamp });
});
assert.ok(store.data.keyStore['reality-nod_key']);
assert.ok(store.data.keyStore['reality-nod_key2']);
await orchestrator.removeNode('nod_key', 'admin', true);
assert.equal(store.data.nodes.some((node) => node.id === 'nod_key'), false);
assert.equal(store.data.keyStore['reality-nod_key'], undefined);
await orchestrator.completeJob('job_del', true, {});
assert.equal(store.data.nodes.some((node) => node.id === 'nod_key2'), false);
assert.equal(store.data.keyStore['reality-nod_key2'], undefined);
});
test('legacy database without keyStore loads with an empty store', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-legacy-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const file = path.join(dir, 'data.json');
await new Store(file).init();
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
delete raw.keyStore;
fs.writeFileSync(file, JSON.stringify(raw));
const reopened = await new Store(file).init();
assert.deepEqual(reopened.data.keyStore, {});
});