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
+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);