feat: panel-hosted Reality keys (keyStore, payload keyMaterial, backfill, cleanup)

This commit is contained in:
aaaamoooo
2026-10-20 07:20:42 +00:00
parent 1b77c610f4
commit 483b891306
6 changed files with 343 additions and 6 deletions
+72
View File
@@ -0,0 +1,72 @@
# Reality 密钥主控托管(Key Hosting)
Reality 密钥对由**控制面**生成、保存和下发,节点只是落地执行。这样节点重装
系统后重新部署的仍是同一把密钥,客户端订阅里的 `pbk`(publicKey)永不变化,
客户无需重新复制配置。
## 数据模型
- 控制面数据库 `nexusgate.json` 新增顶层对象 `keyStore`:
`{ "reality-<nodeId>": { "privateKey": "...", "publicKey": "...", "updatedAt": "..." } }`
- 密钥格式与此前一致:Node JWK 导出的 x25519 密钥对,`privateKey`/`publicKey`
为 base64url 无填充(43 字符),与 `sing-box generate reality-keypair` 输出一致。
- 旧数据库没有 `keyStore` 时自动补为空对象,不影响启动。
## 协议变更
### 下发(控制面 → Agent)
`apply_resource` 任务的 payload 新增可选字段:
```json
{
"resource": { "...": "inbound 里仍是 ${REALITY_PRIVATE:reality-<nodeId>} 占位符" },
"kind": "node",
"keyMaterial": { "reality-<nodeId>": { "privateKey": "...", "publicKey": "..." } }
}
```
- 控制面在构建部署任务时(`rebuildNode` / 对账 `reconcileServer`):节点是
Reality 协议且 `keyStore` 无此 keyId → 面板现场生成并入库;已有则复用。
- 出口(node-exit)任务不带 `keyMaterial`(出口资源没有 Reality 占位符)。
- `/api/jobs` 管理接口不返回 payload,私钥不会经管理 API 泄漏。
### Agent 行为(Go agent)
1. 收到 `keyMaterial` 时,先写入本地 `/etc/nexusgate/keys.json`(0600),
再做占位符 materialize —— 部署的必然是主控托管的那把钥匙。
2. 若某个被使用的 keyId **不是**本次 payload 提供的(本地已有或本地新生成),
在任务结果里回传:`result.artifacts.keyMaterial = { "<keyId>": { privateKey, publicKey } }`。
3. payload 无 `keyMaterial`(旧控制面)时行为与托管前完全一致。
Node 版 agent 不读 `keyMaterial` 字段,照旧本地生成/使用密钥,协议兼容;
其任务结果不带 `keyMaterial` 回传,控制面也不会被它改写(见下)。
### 回填(Agent → 控制面)
任务成功完成(`completeJob`)时,控制面合并 `artifacts.keyMaterial`:
- `keyStore` 无此 keyId → 入库(存量节点密钥由此自动迁入主控)。
- 已有且相同 → 无操作。
- 已有且**不同** → **保留主控的**,写一条 `reality_key_conflict` 审计日志;
下一次下发会把主控密钥重新发给 agent,agent 本地副本随之被纠正。
- 合并后节点的 `artifacts.realityPublicKey` 强制以 `keyStore` 为准,
保证订阅输出稳定。`keyMaterial` 本身不会存进 `node.artifacts`。
## 优先级
**主控 keyStore > 本次下发 payload > 节点本地 keys.json**
payload 的内容就是 keyStore 的副本,所以前两者永远一致;本地仅在主控
从未托管过该密钥的迁移期短暂生效,回填完成后即以主控为准。
## 删除清理
节点记录被真正删除时(草稿直接删除、删除任务确认后、服务器强制遗忘),
对应的 `keyStore["reality-<nodeId>"]` 一并移除。
## 恢复语义
节点重装系统 → 重新注册 agent → 主控重新下发节点资源,payload 携带原
keyMaterial → agent 落地同一私钥 → sing-box 配置与重装前一致,客户端
订阅(pbk + shortId)逐字不变,客户无感。
+49 -3
View File
@@ -5,6 +5,7 @@ const { redactSecrets } = require('./redact');
const {
buildNodeResource, buildNodeExitResource, nodeEmail,
newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, resolveBindAddress,
keyMaterialForNode, dropRealityKey,
SHADOWSOCKS_METHODS,
} = require('./protocols');
@@ -120,7 +121,12 @@ async function rebuildNode(data, node, { includeExit = false } = {}) {
return;
}
node.autoRemoving = false;
queueJob(data, node.serverId, node.id, 'apply_resource', { resource: await resourceForNode(data, node, clients), kind: 'node' });
const payload = { resource: await resourceForNode(data, node, clients), kind: 'node' };
// Reality keys are panel-hosted: ship the stored keypair with the deploy so
// the agent materializes the same private key after any reinstall.
const keyMaterial = keyMaterialForNode(data, node);
if (keyMaterial) payload.keyMaterial = keyMaterial;
queueJob(data, node.serverId, node.id, 'apply_resource', payload);
if (forwardNode(node) && includeExit) {
queueJob(data, node.exitServerId, node.id, 'apply_resource', { resource: await resourceForNodeExit(data, node), kind: 'node-exit' });
}
@@ -195,6 +201,36 @@ function applyDelta(holder, uplink, downlink, sampleEpoch) {
return { upDelta, downDelta };
}
// Merge agent-reported keypairs (artifacts.keyMaterial) into the panel key
// store. The panel store always wins: an agent report only fills a gap (e.g.
// a node created before panel hosting, whose key only exists on the node).
// A conflicting report is audited, never applied — the next deploy re-ships
// the panel key and the agent overwrites its local copy with it.
function mergeKeyMaterialBackfill(data, node, artifacts) {
if (!artifacts || typeof artifacts !== 'object') return;
const reported = artifacts.keyMaterial;
// keyMaterial is transport data for the store, not a node artifact.
delete artifacts.keyMaterial;
if (reported && typeof reported === 'object') {
data.keyStore ||= {};
for (const [keyId, pair] of Object.entries(reported)) {
if (!pair || typeof pair.privateKey !== 'string' || typeof pair.publicKey !== 'string') continue;
const existing = data.keyStore[keyId];
if (!existing) {
data.keyStore[keyId] = { privateKey: pair.privateKey, publicKey: pair.publicKey, updatedAt: nowIso() };
} else if (existing.privateKey !== pair.privateKey || existing.publicKey !== pair.publicKey) {
audit(data, 'agent', 'reality_key_conflict', node.id, { keyId });
}
}
}
// The subscription-facing publicKey always comes from the panel store, so
// it is identical before and after any agent report.
const keyId = node.reality && node.reality.keyId;
if (keyId && data.keyStore && data.keyStore[keyId]) {
artifacts.realityPublicKey = data.keyStore[keyId].publicKey;
}
}
function validateNodeDeploy(data, node) {
const direct = !forwardNode(node);
if (direct) validateEntryProtocol(node.protocol);
@@ -260,6 +296,7 @@ class Orchestrator {
}
node.status = 'removing'; node.error = null;
} else if (deleteRecord) {
dropRealityKey(data, node);
data.nodes = data.nodes.filter((n) => n.id !== node.id);
data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id);
}
@@ -341,7 +378,12 @@ class Orchestrator {
} else {
const clients = activeNodeClients(data, node);
if (!clients.length) continue;
if (isEntry) queueJob(data, serverId, node.id, 'apply_resource', { resource: await resourceForNode(data, node, clients), kind: 'node' });
if (isEntry) {
const payload = { resource: await resourceForNode(data, node, clients), kind: 'node' };
const keyMaterial = keyMaterialForNode(data, node);
if (keyMaterial) payload.keyMaterial = keyMaterial;
queueJob(data, serverId, node.id, 'apply_resource', payload);
}
if (isExit) queueJob(data, serverId, node.id, 'apply_resource', { resource: await resourceForNodeExit(data, node), kind: 'node-exit' });
node.status = 'deploying';
}
@@ -366,13 +408,17 @@ class Orchestrator {
const kind = job.payload && (job.payload.kind || (job.payload.resource && job.payload.resource.meta && job.payload.resource.meta.kind));
if (success && job.action === 'apply_resource') {
if (kind === 'node-exit') node.exitReady = true;
else { node.entryReady = true; node.artifacts = result.artifacts || {}; }
else {
node.entryReady = true; node.artifacts = result.artifacts || {};
mergeKeyMaterialBackfill(data, node, node.artifacts);
}
deriveNodeStatus(node);
} else if (success && job.action === 'delete_resource') {
if (kind === 'node-exit') node.exitReady = false; else node.entryReady = false;
const pending = data.jobs.some((item) => item.nodeId === node.id && item.action === 'delete_resource' && ['queued', 'running'].includes(item.status));
if (!pending && node.status === 'removing') {
if (node.pendingDelete) {
dropRealityKey(data, node);
data.nodes = data.nodes.filter((n) => n.id !== node.id);
data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id);
} else {
+46 -1
View File
@@ -315,6 +315,51 @@ function buildClientUri({ protocol, relayServer, relayPort, credentials, reality
return null;
}
// ---------- Panel-hosted Reality keys ----------
// Reality keypairs live in the panel database (data.keyStore) so that a node
// OS reinstall redeploys the SAME pair and client subscriptions never change.
// Encoding is exactly the agent-side format: Node JWK export of an x25519
// pair, whose base64url no-padding `d`/`x` values match
// `sing-box generate reality-keypair` output byte for byte.
function generateRealityKeyPair() {
const kp = crypto.generateKeyPairSync('x25519');
const priv = kp.privateKey.export({ format: 'jwk' });
const pub = kp.publicKey.export({ format: 'jwk' });
if (!priv.d || !pub.x) throw new Error('X25519 密钥生成失败');
return { privateKey: priv.d, publicKey: pub.x };
}
function realityKeyIdForNode(node) {
return (node.reality && node.reality.keyId) || `reality-${node.id}`;
}
// Returns the stored keypair for a Reality node, generating and storing one
// on first use. Non-Reality nodes return null.
function ensureRealityKey(data, node) {
if (!isRealityProtocol(node.protocol)) return null;
ensureNodeSecrets(node);
const keyId = realityKeyIdForNode(node);
data.keyStore ||= {};
let entry = data.keyStore[keyId];
if (!entry || !entry.privateKey || !entry.publicKey) {
entry = { ...generateRealityKeyPair(), updatedAt: new Date().toISOString() };
data.keyStore[keyId] = entry;
}
return entry;
}
// Payload fragment for apply_resource jobs: { "<keyId>": { privateKey, publicKey } }.
function keyMaterialForNode(data, node) {
const entry = ensureRealityKey(data, node);
if (!entry) return null;
return { [realityKeyIdForNode(node)]: { privateKey: entry.privateKey, publicKey: entry.publicKey } };
}
function dropRealityKey(data, node) {
if (data.keyStore) delete data.keyStore[realityKeyIdForNode(node)];
}
function profile(role, protocol) { return PROFILE_CATALOG.find((item) => item.role === role && item.id === protocol); }
function validateEntryProtocol(protocol) {
const item = profile('relay-ingress', protocol);
@@ -330,5 +375,5 @@ function isRealityProtocol(protocol) { return ['vless-reality-vision', 'vless-re
module.exports = {
PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, nodeEmail, newNodeClientCredentials,
buildNodeResource, buildNodeExitResource, ensureNodeSecrets, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol,
resolveBindAddress
resolveBindAddress, generateRealityKeyPair, realityKeyIdForNode, ensureRealityKey, keyMaterialForNode, dropRealityKey
};
+6 -1
View File
@@ -20,7 +20,10 @@ function emptyData() {
completedJobRetentionDays: 7
},
users: [], servers: [], agents: [], enrollmentTokens: [], customers: [],
nodes: [], nodeClients: [], jobs: [], activity: [], observations: [], subscriptionAccess: [], subscriptionClients: []
nodes: [], nodeClients: [], jobs: [], activity: [], observations: [], subscriptionAccess: [], subscriptionClients: [],
// Panel-hosted Reality keypairs: { "reality-<nodeId>": { privateKey, publicKey, updatedAt } }.
// An object (not a collection), so it is not part of COLLECTIONS validation.
keyStore: {}
};
}
@@ -40,6 +43,7 @@ class Store {
this.data = JSON.parse(raw);
this.data.subscriptionAccess ||= [];
this.data.subscriptionClients ||= [];
this.data.keyStore ||= {};
this.#validate(this.data);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
@@ -81,6 +85,7 @@ class Store {
const copy = structuredClone(next);
copy.subscriptionAccess ||= [];
copy.subscriptionClients ||= [];
copy.keyStore ||= {};
this.#validate(copy);
for (const key of Object.keys(draft)) delete draft[key];
Object.assign(draft, copy);
+2 -1
View File
@@ -7,7 +7,7 @@ const net = require('node:net');
const { Store } = require('./lib/store');
const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth');
const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http');
const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol } = require('./lib/protocols');
const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey } = require('./lib/protocols');
const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator');
const { formatSubscription, nodeClientUri } = require('./lib/subscriptions');
const { redactSecrets } = require('./lib/redact');
@@ -559,6 +559,7 @@ async function handleAdminApi(req, res, pathname) {
}
data.jobs = data.jobs.filter((item) => !residualIds.has(item.nodeId));
data.nodeClients = data.nodeClients.filter((item) => !residualIds.has(item.nodeId));
for (const item of residual) dropRealityKey(data, item);
data.nodes = data.nodes.filter((item) => !residualIds.has(item.id));
for (const agent of data.agents.filter((item) => item.serverId === server.id)) agent.status = 'revoked';
data.servers.splice(index, 1);
+168
View File
@@ -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, {});
});