diff --git a/KEYHOSTING.md b/KEYHOSTING.md new file mode 100644 index 0000000..d99cd9a --- /dev/null +++ b/KEYHOSTING.md @@ -0,0 +1,72 @@ +# Reality 密钥主控托管(Key Hosting) + +Reality 密钥对由**控制面**生成、保存和下发,节点只是落地执行。这样节点重装 +系统后重新部署的仍是同一把密钥,客户端订阅里的 `pbk`(publicKey)永不变化, +客户无需重新复制配置。 + +## 数据模型 + +- 控制面数据库 `nexusgate.json` 新增顶层对象 `keyStore`: + `{ "reality-": { "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-} 占位符" }, + "kind": "node", + "keyMaterial": { "reality-": { "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 = { "": { 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-"]` 一并移除。 + +## 恢复语义 + +节点重装系统 → 重新注册 agent → 主控重新下发节点资源,payload 携带原 +keyMaterial → agent 落地同一私钥 → sing-box 配置与重装前一致,客户端 +订阅(pbk + shortId)逐字不变,客户无感。 diff --git a/lib/orchestrator.js b/lib/orchestrator.js index 024ed2a..526de7c 100644 --- a/lib/orchestrator.js +++ b/lib/orchestrator.js @@ -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 { diff --git a/lib/protocols.js b/lib/protocols.js index edff199..8e09c69 100644 --- a/lib/protocols.js +++ b/lib/protocols.js @@ -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: { "": { 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 }; diff --git a/lib/store.js b/lib/store.js index d137270..48330f1 100644 --- a/lib/store.js +++ b/lib/store.js @@ -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-": { 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); diff --git a/server.js b/server.js index 35cf11a..fb28142 100644 --- a/server.js +++ b/server.js @@ -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); diff --git a/test/reality-keys.test.js b/test/reality-keys.test.js new file mode 100644 index 0000000..a99c08f --- /dev/null +++ b/test/reality-keys.test.js @@ -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, {}); +});