feat: panel-hosted Reality keys (keyStore, payload keyMaterial, backfill, cleanup)
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
'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, {});
|
||||
});
|
||||
Reference in New Issue
Block a user