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 {