feat: panel-hosted Reality keys (keyStore, payload keyMaterial, backfill, cleanup)
This commit is contained in:
@@ -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
@@ -5,6 +5,7 @@ const { redactSecrets } = require('./redact');
|
|||||||
const {
|
const {
|
||||||
buildNodeResource, buildNodeExitResource, nodeEmail,
|
buildNodeResource, buildNodeExitResource, nodeEmail,
|
||||||
newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, resolveBindAddress,
|
newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, resolveBindAddress,
|
||||||
|
keyMaterialForNode, dropRealityKey,
|
||||||
SHADOWSOCKS_METHODS,
|
SHADOWSOCKS_METHODS,
|
||||||
} = require('./protocols');
|
} = require('./protocols');
|
||||||
|
|
||||||
@@ -120,7 +121,12 @@ async function rebuildNode(data, node, { includeExit = false } = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
node.autoRemoving = false;
|
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) {
|
if (forwardNode(node) && includeExit) {
|
||||||
queueJob(data, node.exitServerId, node.id, 'apply_resource', { resource: await resourceForNodeExit(data, node), kind: 'node-exit' });
|
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 };
|
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) {
|
function validateNodeDeploy(data, node) {
|
||||||
const direct = !forwardNode(node);
|
const direct = !forwardNode(node);
|
||||||
if (direct) validateEntryProtocol(node.protocol);
|
if (direct) validateEntryProtocol(node.protocol);
|
||||||
@@ -260,6 +296,7 @@ class Orchestrator {
|
|||||||
}
|
}
|
||||||
node.status = 'removing'; node.error = null;
|
node.status = 'removing'; node.error = null;
|
||||||
} else if (deleteRecord) {
|
} else if (deleteRecord) {
|
||||||
|
dropRealityKey(data, node);
|
||||||
data.nodes = data.nodes.filter((n) => n.id !== node.id);
|
data.nodes = data.nodes.filter((n) => n.id !== node.id);
|
||||||
data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id);
|
data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id);
|
||||||
}
|
}
|
||||||
@@ -341,7 +378,12 @@ class Orchestrator {
|
|||||||
} else {
|
} else {
|
||||||
const clients = activeNodeClients(data, node);
|
const clients = activeNodeClients(data, node);
|
||||||
if (!clients.length) continue;
|
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' });
|
if (isExit) queueJob(data, serverId, node.id, 'apply_resource', { resource: await resourceForNodeExit(data, node), kind: 'node-exit' });
|
||||||
node.status = 'deploying';
|
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));
|
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 (success && job.action === 'apply_resource') {
|
||||||
if (kind === 'node-exit') node.exitReady = true;
|
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);
|
deriveNodeStatus(node);
|
||||||
} else if (success && job.action === 'delete_resource') {
|
} else if (success && job.action === 'delete_resource') {
|
||||||
if (kind === 'node-exit') node.exitReady = false; else node.entryReady = false;
|
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));
|
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 (!pending && node.status === 'removing') {
|
||||||
if (node.pendingDelete) {
|
if (node.pendingDelete) {
|
||||||
|
dropRealityKey(data, node);
|
||||||
data.nodes = data.nodes.filter((n) => n.id !== node.id);
|
data.nodes = data.nodes.filter((n) => n.id !== node.id);
|
||||||
data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id);
|
data.nodeClients = data.nodeClients.filter((nc) => nc.nodeId !== node.id);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+46
-1
@@ -315,6 +315,51 @@ function buildClientUri({ protocol, relayServer, relayPort, credentials, reality
|
|||||||
return null;
|
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 profile(role, protocol) { return PROFILE_CATALOG.find((item) => item.role === role && item.id === protocol); }
|
||||||
function validateEntryProtocol(protocol) {
|
function validateEntryProtocol(protocol) {
|
||||||
const item = profile('relay-ingress', protocol);
|
const item = profile('relay-ingress', protocol);
|
||||||
@@ -330,5 +375,5 @@ function isRealityProtocol(protocol) { return ['vless-reality-vision', 'vless-re
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, nodeEmail, newNodeClientCredentials,
|
PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, nodeEmail, newNodeClientCredentials,
|
||||||
buildNodeResource, buildNodeExitResource, ensureNodeSecrets, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol,
|
buildNodeResource, buildNodeExitResource, ensureNodeSecrets, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol,
|
||||||
resolveBindAddress
|
resolveBindAddress, generateRealityKeyPair, realityKeyIdForNode, ensureRealityKey, keyMaterialForNode, dropRealityKey
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-1
@@ -20,7 +20,10 @@ function emptyData() {
|
|||||||
completedJobRetentionDays: 7
|
completedJobRetentionDays: 7
|
||||||
},
|
},
|
||||||
users: [], servers: [], agents: [], enrollmentTokens: [], customers: [],
|
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 = JSON.parse(raw);
|
||||||
this.data.subscriptionAccess ||= [];
|
this.data.subscriptionAccess ||= [];
|
||||||
this.data.subscriptionClients ||= [];
|
this.data.subscriptionClients ||= [];
|
||||||
|
this.data.keyStore ||= {};
|
||||||
this.#validate(this.data);
|
this.#validate(this.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code !== 'ENOENT') throw error;
|
if (error.code !== 'ENOENT') throw error;
|
||||||
@@ -81,6 +85,7 @@ class Store {
|
|||||||
const copy = structuredClone(next);
|
const copy = structuredClone(next);
|
||||||
copy.subscriptionAccess ||= [];
|
copy.subscriptionAccess ||= [];
|
||||||
copy.subscriptionClients ||= [];
|
copy.subscriptionClients ||= [];
|
||||||
|
copy.keyStore ||= {};
|
||||||
this.#validate(copy);
|
this.#validate(copy);
|
||||||
for (const key of Object.keys(draft)) delete draft[key];
|
for (const key of Object.keys(draft)) delete draft[key];
|
||||||
Object.assign(draft, copy);
|
Object.assign(draft, copy);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const net = require('node:net');
|
|||||||
const { Store } = require('./lib/store');
|
const { Store } = require('./lib/store');
|
||||||
const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth');
|
const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth');
|
||||||
const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http');
|
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 { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator');
|
||||||
const { formatSubscription, nodeClientUri } = require('./lib/subscriptions');
|
const { formatSubscription, nodeClientUri } = require('./lib/subscriptions');
|
||||||
const { redactSecrets } = require('./lib/redact');
|
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.jobs = data.jobs.filter((item) => !residualIds.has(item.nodeId));
|
||||||
data.nodeClients = data.nodeClients.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));
|
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';
|
for (const agent of data.agents.filter((item) => item.serverId === server.id)) agent.status = 'revoked';
|
||||||
data.servers.splice(index, 1);
|
data.servers.splice(index, 1);
|
||||||
|
|||||||
@@ -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