feat: panel-hosted Reality keys (keyStore, payload keyMaterial, backfill, cleanup)
This commit is contained in:
+49
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user