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 {
|
||||
|
||||
+46
-1
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user