@lyrify/znl 0.5.1 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -4
- package/package.json +14 -2
- package/src/SendQueue.js +51 -17
- package/src/ZNL.js +366 -53
- package/src/constants.js +9 -0
- package/src/security.js +19 -1
package/README.md
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
- 加密开关 `encrypted`:
|
|
14
14
|
- `false`:明文模式(不签名/不加密)
|
|
15
15
|
- `true`:签名 + 防重放 + payload 透明加密(AES-256-GCM)
|
|
16
|
-
-
|
|
16
|
+
- 可选关闭 payload 摘要校验(`enablePayloadDigest=false`)以提升性能
|
|
17
|
+
- 建议 master/slave 两端保持一致配置,避免认证不一致
|
|
18
|
+
- `authKey` 或 `authKeyMap` 仅在 `encrypted=true` 时必填
|
|
19
|
+
- `authKeyMap` 支持 master 按 `slaveId` 配置不同 key(未命中时会回退到 `authKey`)
|
|
17
20
|
- Payload 支持 `string`、`Buffer`、`Uint8Array` 及其数组(多帧)
|
|
18
21
|
|
|
19
22
|
## 安装
|
|
@@ -99,9 +102,13 @@ new ZNL({
|
|
|
99
102
|
endpoints: {
|
|
100
103
|
router: "tcp://127.0.0.1:6003",
|
|
101
104
|
},
|
|
102
|
-
maxPending:
|
|
105
|
+
maxPending: 1000,
|
|
103
106
|
authKey: "",
|
|
107
|
+
authKeyMap: { "slave-001": "k1", "slave-002": "k2" },
|
|
108
|
+
heartbeatInterval: 3000,
|
|
109
|
+
heartbeatTimeoutMs: 0,
|
|
104
110
|
encrypted: false,
|
|
111
|
+
enablePayloadDigest: true,
|
|
105
112
|
maxTimeSkewMs: 30000,
|
|
106
113
|
replayWindowMs: 120000,
|
|
107
114
|
});
|
|
@@ -112,9 +119,13 @@ new ZNL({
|
|
|
112
119
|
| `role` | ✓ | 节点角色,`"master"` 或 `"slave"` |
|
|
113
120
|
| `id` | ✓ | 节点唯一标识;slave 端同时作为 ZMQ `routingId` |
|
|
114
121
|
| `endpoints.router` | | ROUTER 端点,默认 `tcp://127.0.0.1:6003` |
|
|
115
|
-
| `maxPending` | | 最大并发 RPC
|
|
116
|
-
| `authKey` | | 共享认证 Key
|
|
122
|
+
| `maxPending` | | 最大并发 RPC 请求数,默认 `1000`;`0` 表示不限制 |
|
|
123
|
+
| `authKey` | | 共享认证 Key;与 `authKeyMap` 二选一(`encrypted=true` 时至少提供一个) |
|
|
124
|
+
| `authKeyMap` | | master 侧 slaveId → authKey 映射;未命中时回退到 `authKey` |
|
|
125
|
+
| `heartbeatInterval` | | 心跳间隔(毫秒),默认 `3000`,`0` 表示禁用心跳 |
|
|
126
|
+
| `heartbeatTimeoutMs` | | 心跳超时时间(毫秒),默认 `0` 表示使用 `heartbeatInterval × 3` |
|
|
117
127
|
| `encrypted` | | 是否启用加密:`false`(默认,明文) / `true`(签名+防重放+透明加密) |
|
|
128
|
+
| `enablePayloadDigest` | | 是否启用 payload 摘要校验,默认 `true`(关闭可提升性能) |
|
|
118
129
|
| `maxTimeSkewMs` | | 时间戳最大允许偏移(毫秒),默认 `30000`,用于防重放校验 |
|
|
119
130
|
| `replayWindowMs` | | nonce 重放缓存窗口(毫秒),默认 `120000` |
|
|
120
131
|
|
|
@@ -150,6 +161,18 @@ new ZNL({
|
|
|
150
161
|
- `identityOrHandler` 为函数时:注册 master 侧自动回复处理器(Slave 发来 RPC 请求时触发)
|
|
151
162
|
- `identityOrHandler` 为 identity(slave ID)时:Master 主动向指定 Slave 发送 RPC 请求并等待响应,返回 `Promise<Buffer | Array>`
|
|
152
163
|
|
|
164
|
+
### `addAuthKey(slaveId, authKey)`
|
|
165
|
+
|
|
166
|
+
**Master 侧调用:**
|
|
167
|
+
|
|
168
|
+
- 动态添加/更新某个 slave 的 authKey(立即生效)
|
|
169
|
+
|
|
170
|
+
### `removeAuthKey(slaveId)`
|
|
171
|
+
|
|
172
|
+
**Master 侧调用:**
|
|
173
|
+
|
|
174
|
+
- 移除某个 slave 的 authKey(立即生效),并触发 `slave_disconnected`
|
|
175
|
+
|
|
153
176
|
### `options.timeoutMs`
|
|
154
177
|
|
|
155
178
|
单次 RPC 请求超时时间,默认 `5000` ms。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyrify/znl",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
4
4
|
"description": "ZNL - ZeroMQ Node Link",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -35,12 +35,24 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"zeromq": "^6.5.0"
|
|
37
37
|
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"eslint": "^9.39.4",
|
|
40
|
+
"eslint-plugin-import": "^2.32.0",
|
|
41
|
+
"prettier": "^3.8.1"
|
|
42
|
+
},
|
|
38
43
|
"scripts": {
|
|
39
44
|
"example:master": "node test/master/index.js",
|
|
40
45
|
"example:slave": "node test/slave/index.js",
|
|
41
46
|
"test": "node test/run.js",
|
|
47
|
+
"test:unit": "node --test \"test/unit/**/*.test.js\"",
|
|
48
|
+
"test:all": "pnpm test && pnpm test:unit",
|
|
42
49
|
"test:echo": "node test/master/test-echo-server.js",
|
|
43
50
|
"test:100": "node test/slave/test-100-concurrent.js",
|
|
44
|
-
"
|
|
51
|
+
"test:capture": "node test/capture-frames.js",
|
|
52
|
+
"check": "node --check index.js && node --check src/constants.js && node --check src/protocol.js && node --check src/security.js && node --check src/PendingManager.js && node --check src/SendQueue.js && node --check src/ZNL.js && node --check test/master/index.js && node --check test/slave/index.js && node --check test/master/test-echo-server.js && node --check test/slave/test-100-concurrent.js && node --check test/run.js",
|
|
53
|
+
"lint": "eslint .",
|
|
54
|
+
"lint:fix": "eslint . --fix",
|
|
55
|
+
"format": "prettier . --check",
|
|
56
|
+
"format:write": "prettier . --write"
|
|
45
57
|
}
|
|
46
58
|
}
|
package/src/SendQueue.js
CHANGED
|
@@ -6,17 +6,15 @@
|
|
|
6
6
|
* 虽然 zeromq.js v6 在 JS 层做了一定保护,但显式串行化更安全可靠。
|
|
7
7
|
*
|
|
8
8
|
* 实现原理:
|
|
9
|
-
* 为每个 socket
|
|
10
|
-
*
|
|
11
|
-
* 无论前一个任务成功或失败,队列都会继续执行下一个任务。
|
|
9
|
+
* 为每个 socket 通道维护一个任务队列与独立消费循环。
|
|
10
|
+
* 通过单线程 async loop 串行执行,避免长 Promise 链增长。
|
|
12
11
|
*/
|
|
13
|
-
|
|
14
12
|
export class SendQueue {
|
|
15
13
|
/**
|
|
16
|
-
*
|
|
17
|
-
* @type {Map<string,
|
|
14
|
+
* 通道级队列与运行状态
|
|
15
|
+
* @type {Map<string, { queue: Array<{ task: Function, resolve: Function, reject: Function }>, running: boolean }>}
|
|
18
16
|
*/
|
|
19
|
-
#
|
|
17
|
+
#channels = new Map();
|
|
20
18
|
|
|
21
19
|
/**
|
|
22
20
|
* 将发送任务追加到指定通道的队列尾部
|
|
@@ -26,22 +24,58 @@ export class SendQueue {
|
|
|
26
24
|
* @returns {Promise<void>} 本次任务的 Promise(可用于错误捕获)
|
|
27
25
|
*/
|
|
28
26
|
enqueue(channel, task) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
// 无论前一个任务成功或失败,都继续执行当前任务
|
|
32
|
-
const run = tail.then(task, task);
|
|
27
|
+
const name = String(channel);
|
|
28
|
+
const entry = this.#ensureChannel(name);
|
|
33
29
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
entry.queue.push({ task, resolve, reject });
|
|
32
|
+
if (!entry.running) this.#drain(name, entry);
|
|
33
|
+
});
|
|
38
34
|
}
|
|
39
35
|
|
|
40
36
|
/**
|
|
41
37
|
* 清空所有通道的队列引用(节点停止时调用)
|
|
42
|
-
*
|
|
38
|
+
* 注意:已入队但未执行的任务不会被取消,仅释放引用
|
|
43
39
|
*/
|
|
44
40
|
clear() {
|
|
45
|
-
this.#
|
|
41
|
+
this.#channels.clear();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 确保通道数据结构存在
|
|
46
|
+
* @param {string} name
|
|
47
|
+
* @returns {{ queue: Array, running: boolean }}
|
|
48
|
+
*/
|
|
49
|
+
#ensureChannel(name) {
|
|
50
|
+
let entry = this.#channels.get(name);
|
|
51
|
+
if (!entry) {
|
|
52
|
+
entry = { queue: [], running: false };
|
|
53
|
+
this.#channels.set(name, entry);
|
|
54
|
+
}
|
|
55
|
+
return entry;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 串行消费队列
|
|
60
|
+
* @param {string} name
|
|
61
|
+
* @param {{ queue: Array, running: boolean }} entry
|
|
62
|
+
*/
|
|
63
|
+
async #drain(name, entry) {
|
|
64
|
+
entry.running = true;
|
|
65
|
+
|
|
66
|
+
while (entry.queue.length > 0) {
|
|
67
|
+
const { task, resolve, reject } = entry.queue.shift();
|
|
68
|
+
try {
|
|
69
|
+
await task();
|
|
70
|
+
resolve();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
reject(error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
entry.running = false;
|
|
77
|
+
|
|
78
|
+
// 清理空通道,避免 Map 无限增长
|
|
79
|
+
if (entry.queue.length === 0) this.#channels.delete(name);
|
|
46
80
|
}
|
|
47
81
|
}
|
package/src/ZNL.js
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* 心跳机制:
|
|
21
21
|
* - slave 每隔 heartbeatInterval ms 向 master 发送一帧心跳
|
|
22
22
|
* - master 每隔 heartbeatInterval ms 扫描一次在线列表
|
|
23
|
-
* - 超过
|
|
23
|
+
* - 超过 heartbeatTimeoutMs 未收到心跳,判定 slave 已崩溃并移除
|
|
24
|
+
* (heartbeatTimeoutMs <= 0 时,默认按 heartbeatInterval × 3 计算)
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
27
|
import { randomUUID } from "node:crypto";
|
|
@@ -30,6 +31,9 @@ import * as zmq from "zeromq";
|
|
|
30
31
|
import {
|
|
31
32
|
DEFAULT_ENDPOINTS,
|
|
32
33
|
DEFAULT_HEARTBEAT_INTERVAL,
|
|
34
|
+
DEFAULT_HEARTBEAT_TIMEOUT_MS,
|
|
35
|
+
DEFAULT_MAX_PENDING,
|
|
36
|
+
DEFAULT_ENABLE_PAYLOAD_DIGEST,
|
|
33
37
|
CONTROL_PREFIX,
|
|
34
38
|
CONTROL_HEARTBEAT,
|
|
35
39
|
SECURITY_ENVELOPE_VERSION,
|
|
@@ -59,7 +63,6 @@ import {
|
|
|
59
63
|
generateNonce,
|
|
60
64
|
nowMs,
|
|
61
65
|
canonicalSignInput,
|
|
62
|
-
signText,
|
|
63
66
|
encodeAuthProofToken,
|
|
64
67
|
decodeAuthProofToken,
|
|
65
68
|
encryptFrames,
|
|
@@ -118,13 +121,25 @@ export class ZNL extends EventEmitter {
|
|
|
118
121
|
/** @type {Function|null} slave 侧收到 master 请求时的自动回复处理器 */
|
|
119
122
|
#dealerAutoHandler = null;
|
|
120
123
|
|
|
124
|
+
/** @type {number} 心跳超时时间(ms),0 表示使用 interval × 3 */
|
|
125
|
+
#heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
126
|
+
|
|
127
|
+
/** @type {boolean} 是否启用 payload 摘要校验(安全模式用) */
|
|
128
|
+
#enablePayloadDigest = DEFAULT_ENABLE_PAYLOAD_DIGEST;
|
|
129
|
+
|
|
130
|
+
/** @type {Map<string, string>} master 侧 authKey 配置表(按 slaveId) */
|
|
131
|
+
#authKeyMap = new Map();
|
|
132
|
+
|
|
133
|
+
/** @type {Map<string, { signKey: Buffer, encryptKey: Buffer }>} master 侧派生密钥缓存 */
|
|
134
|
+
#slaveKeyCache = new Map();
|
|
135
|
+
|
|
121
136
|
// ─── PUB/SUB 状态 ────────────────────────────────────────────────────────
|
|
122
137
|
|
|
123
138
|
/**
|
|
124
139
|
* master 侧:已注册的在线 slave 表
|
|
125
140
|
* key = slaveId(字符串)
|
|
126
|
-
* value = { identity: Buffer, lastSeen: number }
|
|
127
|
-
* @type {Map<string, { identity: Buffer, lastSeen: number }>}
|
|
141
|
+
* value = { identity: Buffer, lastSeen: number, signKey: Buffer|null, encryptKey: Buffer|null }
|
|
142
|
+
* @type {Map<string, { identity: Buffer, lastSeen: number, signKey: Buffer|null, encryptKey: Buffer|null }>}
|
|
128
143
|
*/
|
|
129
144
|
#slaves = new Map();
|
|
130
145
|
|
|
@@ -174,22 +189,28 @@ export class ZNL extends EventEmitter {
|
|
|
174
189
|
* role : "master"|"slave",
|
|
175
190
|
* id : string,
|
|
176
191
|
* endpoints? : { router?: string },
|
|
177
|
-
* maxPending?
|
|
178
|
-
* authKey?
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
192
|
+
* maxPending? : number,
|
|
193
|
+
* authKey? : string,
|
|
194
|
+
* authKeyMap? : Record<string, string>,
|
|
195
|
+
* heartbeatInterval? : number,
|
|
196
|
+
* heartbeatTimeoutMs? : number,
|
|
197
|
+
* encrypted? : boolean,
|
|
198
|
+
* enablePayloadDigest? : boolean,
|
|
199
|
+
* maxTimeSkewMs? : number,
|
|
200
|
+
* replayWindowMs? : number,
|
|
183
201
|
* }} options
|
|
184
202
|
*/
|
|
185
203
|
constructor({
|
|
186
204
|
role,
|
|
187
205
|
id,
|
|
188
206
|
endpoints = {},
|
|
189
|
-
maxPending =
|
|
207
|
+
maxPending = DEFAULT_MAX_PENDING,
|
|
190
208
|
authKey = "",
|
|
209
|
+
authKeyMap = null,
|
|
191
210
|
heartbeatInterval = DEFAULT_HEARTBEAT_INTERVAL,
|
|
211
|
+
heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS,
|
|
192
212
|
encrypted = false,
|
|
213
|
+
enablePayloadDigest = DEFAULT_ENABLE_PAYLOAD_DIGEST,
|
|
193
214
|
maxTimeSkewMs = MAX_TIME_SKEW_MS,
|
|
194
215
|
replayWindowMs = REPLAY_WINDOW_MS,
|
|
195
216
|
} = {}) {
|
|
@@ -207,16 +228,35 @@ export class ZNL extends EventEmitter {
|
|
|
207
228
|
this.endpoints = { ...DEFAULT_ENDPOINTS, ...endpoints };
|
|
208
229
|
this.authKey = authKey == null ? "" : String(authKey);
|
|
209
230
|
|
|
231
|
+
if (this.role === "master") {
|
|
232
|
+
if (authKeyMap != null) {
|
|
233
|
+
if (typeof authKeyMap !== "object") {
|
|
234
|
+
throw new TypeError(
|
|
235
|
+
"`authKeyMap` 必须是对象(slaveId -> authKey)。",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
for (const [slaveId, key] of Object.entries(authKeyMap)) {
|
|
239
|
+
if (key == null) continue;
|
|
240
|
+
this.#authKeyMap.set(String(slaveId), String(key));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
210
245
|
this.encrypted = Boolean(encrypted);
|
|
211
246
|
this.#secureEnabled = this.encrypted;
|
|
212
247
|
|
|
213
248
|
if (this.#secureEnabled) {
|
|
214
|
-
|
|
215
|
-
|
|
249
|
+
const hasAuthMap = this.role === "master" && authKeyMap != null;
|
|
250
|
+
if (!this.authKey && !hasAuthMap) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
"启用 encrypted=true 时,必须提供非空 authKey 或 authKeyMap。",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (this.authKey) {
|
|
256
|
+
const { signKey, encryptKey } = deriveKeys(this.authKey);
|
|
257
|
+
this.#signKey = signKey;
|
|
258
|
+
this.#encryptKey = encryptKey;
|
|
216
259
|
}
|
|
217
|
-
const { signKey, encryptKey } = deriveKeys(this.authKey);
|
|
218
|
-
this.#signKey = signKey;
|
|
219
|
-
this.#encryptKey = encryptKey;
|
|
220
260
|
}
|
|
221
261
|
|
|
222
262
|
this.#pending = new PendingManager(maxPending);
|
|
@@ -224,6 +264,15 @@ export class ZNL extends EventEmitter {
|
|
|
224
264
|
this.#heartbeatInterval =
|
|
225
265
|
this.#normalizeHeartbeatInterval(heartbeatInterval);
|
|
226
266
|
|
|
267
|
+
// 是否启用 payload 摘要校验(安全模式用)
|
|
268
|
+
this.#enablePayloadDigest = Boolean(enablePayloadDigest);
|
|
269
|
+
|
|
270
|
+
// 心跳超时:<=0 则由 heartbeatInterval 推导
|
|
271
|
+
this.#heartbeatTimeoutMs = this.#normalizePositiveInt(
|
|
272
|
+
heartbeatTimeoutMs,
|
|
273
|
+
DEFAULT_HEARTBEAT_TIMEOUT_MS,
|
|
274
|
+
);
|
|
275
|
+
|
|
227
276
|
this.#maxTimeSkewMs = this.#normalizePositiveInt(
|
|
228
277
|
maxTimeSkewMs,
|
|
229
278
|
MAX_TIME_SKEW_MS,
|
|
@@ -334,19 +383,34 @@ export class ZNL extends EventEmitter {
|
|
|
334
383
|
if (this.#slaves.size === 0) return;
|
|
335
384
|
|
|
336
385
|
const topicText = String(topic);
|
|
337
|
-
const payloadFrames = this.#sealPayloadFrames(
|
|
338
|
-
"publish",
|
|
339
|
-
topicText,
|
|
340
|
-
payload,
|
|
341
|
-
);
|
|
342
|
-
const authProof = this.#secureEnabled
|
|
343
|
-
? this.#createAuthProof("publish", topicText, payloadFrames)
|
|
344
|
-
: "";
|
|
345
|
-
|
|
346
|
-
const frames = buildPublishFrames(topicText, payloadFrames, authProof);
|
|
347
386
|
|
|
348
387
|
for (const [slaveId, entry] of this.#slaves) {
|
|
388
|
+
const keys = this.#secureEnabled ? this.#resolveSlaveKeys(slaveId) : null;
|
|
389
|
+
if (this.#secureEnabled && !keys) {
|
|
390
|
+
if (this.#slaves.delete(slaveId)) {
|
|
391
|
+
this.emit("slave_disconnected", slaveId);
|
|
392
|
+
}
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const payloadFrames = this.#sealPayloadFrames(
|
|
397
|
+
"publish",
|
|
398
|
+
topicText,
|
|
399
|
+
payload,
|
|
400
|
+
keys?.encryptKey ?? null,
|
|
401
|
+
);
|
|
402
|
+
const authProof = this.#secureEnabled
|
|
403
|
+
? this.#createAuthProof(
|
|
404
|
+
"publish",
|
|
405
|
+
topicText,
|
|
406
|
+
payloadFrames,
|
|
407
|
+
keys.signKey,
|
|
408
|
+
)
|
|
409
|
+
: "";
|
|
410
|
+
|
|
411
|
+
const frames = buildPublishFrames(topicText, payloadFrames, authProof);
|
|
349
412
|
const idFrame = identityToBuffer(entry.identity);
|
|
413
|
+
|
|
350
414
|
this.#sendQueue
|
|
351
415
|
.enqueue("router", () => socket.send([idFrame, ...frames]))
|
|
352
416
|
.catch(() => {
|
|
@@ -402,6 +466,63 @@ export class ZNL extends EventEmitter {
|
|
|
402
466
|
return [...this.#slaves.keys()];
|
|
403
467
|
}
|
|
404
468
|
|
|
469
|
+
/**
|
|
470
|
+
* 【Master 侧】动态添加/更新某个 slave 的 authKey(立即生效)
|
|
471
|
+
* @param {string} slaveId
|
|
472
|
+
* @param {string} authKey
|
|
473
|
+
* @returns {this}
|
|
474
|
+
*/
|
|
475
|
+
addAuthKey(slaveId, authKey) {
|
|
476
|
+
if (this.role !== "master") {
|
|
477
|
+
throw new Error("addAuthKey() 只能在 master 侧调用。");
|
|
478
|
+
}
|
|
479
|
+
const id = String(slaveId ?? "");
|
|
480
|
+
if (!id) {
|
|
481
|
+
throw new Error("slaveId 不能为空。");
|
|
482
|
+
}
|
|
483
|
+
const key = authKey == null ? "" : String(authKey);
|
|
484
|
+
if (!key) {
|
|
485
|
+
throw new Error("authKey 不能为空。");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
this.#authKeyMap.set(id, key);
|
|
489
|
+
|
|
490
|
+
if (this.#secureEnabled) {
|
|
491
|
+
const derived = deriveKeys(key);
|
|
492
|
+
this.#slaveKeyCache.set(id, derived);
|
|
493
|
+
const entry = this.#slaves.get(id);
|
|
494
|
+
if (entry) {
|
|
495
|
+
entry.signKey = derived.signKey;
|
|
496
|
+
entry.encryptKey = derived.encryptKey;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return this;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 【Master 侧】移除某个 slave 的 authKey(立即生效)
|
|
504
|
+
* @param {string} slaveId
|
|
505
|
+
* @returns {this}
|
|
506
|
+
*/
|
|
507
|
+
removeAuthKey(slaveId) {
|
|
508
|
+
if (this.role !== "master") {
|
|
509
|
+
throw new Error("removeAuthKey() 只能在 master 侧调用。");
|
|
510
|
+
}
|
|
511
|
+
const id = String(slaveId ?? "");
|
|
512
|
+
if (!id) {
|
|
513
|
+
throw new Error("slaveId 不能为空。");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
this.#authKeyMap.delete(id);
|
|
517
|
+
this.#slaveKeyCache.delete(id);
|
|
518
|
+
|
|
519
|
+
if (this.#slaves.has(id)) {
|
|
520
|
+
this.#slaves.delete(id);
|
|
521
|
+
this.emit("slave_disconnected", id);
|
|
522
|
+
}
|
|
523
|
+
return this;
|
|
524
|
+
}
|
|
525
|
+
|
|
405
526
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
406
527
|
// 生命周期内部实现
|
|
407
528
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -464,6 +585,7 @@ export class ZNL extends EventEmitter {
|
|
|
464
585
|
this.#sockets = {};
|
|
465
586
|
this.#sendQueue.clear();
|
|
466
587
|
this.#slaves.clear();
|
|
588
|
+
this.#slaveKeyCache.clear();
|
|
467
589
|
this.#masterNodeId = null;
|
|
468
590
|
this.#replayGuard.clear();
|
|
469
591
|
}
|
|
@@ -476,7 +598,7 @@ export class ZNL extends EventEmitter {
|
|
|
476
598
|
* 初始化 Master 侧 ROUTER socket(bind 模式),并启动读循环和死节点扫描定时器
|
|
477
599
|
*/
|
|
478
600
|
async #startMasterSockets() {
|
|
479
|
-
const router = new zmq.Router();
|
|
601
|
+
const router = new zmq.Router({ handover: true });
|
|
480
602
|
await router.bind(this.endpoints.router);
|
|
481
603
|
this.#sockets.router = router;
|
|
482
604
|
|
|
@@ -533,41 +655,80 @@ export class ZNL extends EventEmitter {
|
|
|
533
655
|
// ── 心跳 ────────────────────────────────────────────────────────────────
|
|
534
656
|
if (parsed.kind === "heartbeat") {
|
|
535
657
|
if (this.#secureEnabled) {
|
|
658
|
+
const keys = this.#resolveSlaveKeys(identityText);
|
|
659
|
+
if (!keys) {
|
|
660
|
+
this.#emitAuthFailed(event, "未配置该 slave 的 authKey。");
|
|
661
|
+
if (this.#slaves.delete(identityText)) {
|
|
662
|
+
this.emit("slave_disconnected", identityText);
|
|
663
|
+
}
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
536
667
|
const v = this.#verifyIncomingProof({
|
|
537
668
|
kind: "heartbeat",
|
|
538
669
|
proofToken: parsed.authProof,
|
|
539
670
|
requestId: "",
|
|
540
671
|
payloadFrames: [],
|
|
541
672
|
expectedNodeId: identityText,
|
|
673
|
+
signKey: keys.signKey,
|
|
542
674
|
});
|
|
543
675
|
if (!v.ok) {
|
|
544
676
|
this.#emitAuthFailed(event, v.error);
|
|
545
677
|
return;
|
|
546
678
|
}
|
|
679
|
+
|
|
680
|
+
this.#ensureSlaveOnline(identityText, identity, {
|
|
681
|
+
touch: true,
|
|
682
|
+
signKey: keys.signKey,
|
|
683
|
+
encryptKey: keys.encryptKey,
|
|
684
|
+
});
|
|
685
|
+
return;
|
|
547
686
|
}
|
|
548
687
|
|
|
549
|
-
|
|
550
|
-
|
|
688
|
+
// 心跳视为在线确认:必要时自动补注册
|
|
689
|
+
this.#ensureSlaveOnline(identityText, identity, { touch: true });
|
|
551
690
|
return;
|
|
552
691
|
}
|
|
553
692
|
|
|
554
693
|
// ── 注册 ────────────────────────────────────────────────────────────────
|
|
555
694
|
if (parsed.kind === "register") {
|
|
556
695
|
if (this.#secureEnabled) {
|
|
696
|
+
const keys = this.#resolveSlaveKeys(identityText);
|
|
697
|
+
if (!keys) {
|
|
698
|
+
this.#emitAuthFailed(event, "未配置该 slave 的 authKey。");
|
|
699
|
+
if (this.#slaves.delete(identityText)) {
|
|
700
|
+
this.emit("slave_disconnected", identityText);
|
|
701
|
+
}
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
557
705
|
const v = this.#verifyIncomingProof({
|
|
558
706
|
kind: "register",
|
|
559
707
|
proofToken: parsed.authKey, // register 复用 authKey 字段承载 proof
|
|
560
708
|
requestId: "",
|
|
561
709
|
payloadFrames: [],
|
|
562
710
|
expectedNodeId: identityText,
|
|
711
|
+
signKey: keys.signKey,
|
|
563
712
|
});
|
|
564
713
|
if (!v.ok) {
|
|
565
714
|
this.#emitAuthFailed(event, v.error);
|
|
566
715
|
return;
|
|
567
716
|
}
|
|
717
|
+
|
|
718
|
+
this.#ensureSlaveOnline(identityText, identity, {
|
|
719
|
+
touch: true,
|
|
720
|
+
signKey: keys.signKey,
|
|
721
|
+
encryptKey: keys.encryptKey,
|
|
722
|
+
});
|
|
723
|
+
return;
|
|
568
724
|
}
|
|
569
725
|
|
|
570
|
-
this.#slaves.set(identityText, {
|
|
726
|
+
this.#slaves.set(identityText, {
|
|
727
|
+
identity,
|
|
728
|
+
lastSeen: Date.now(),
|
|
729
|
+
signKey: null,
|
|
730
|
+
encryptKey: null,
|
|
731
|
+
});
|
|
571
732
|
this.emit("slave_connected", identityText);
|
|
572
733
|
return;
|
|
573
734
|
}
|
|
@@ -585,18 +746,35 @@ export class ZNL extends EventEmitter {
|
|
|
585
746
|
let finalFrames = parsed.payloadFrames;
|
|
586
747
|
|
|
587
748
|
if (this.#secureEnabled) {
|
|
749
|
+
const keys = this.#resolveSlaveKeys(identityText);
|
|
750
|
+
if (!keys) {
|
|
751
|
+
this.#emitAuthFailed(event, "未配置该 slave 的 authKey。");
|
|
752
|
+
if (this.#slaves.delete(identityText)) {
|
|
753
|
+
this.emit("slave_disconnected", identityText);
|
|
754
|
+
}
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
588
758
|
const v = this.#verifyIncomingProof({
|
|
589
759
|
kind: "request",
|
|
590
760
|
proofToken: parsed.authKey, // request 复用 authKey 字段承载 proof
|
|
591
761
|
requestId: parsed.requestId,
|
|
592
762
|
payloadFrames: parsed.payloadFrames,
|
|
593
763
|
expectedNodeId: identityText,
|
|
764
|
+
signKey: keys.signKey,
|
|
594
765
|
});
|
|
595
766
|
if (!v.ok) {
|
|
596
767
|
this.#emitAuthFailed(event, v.error);
|
|
597
768
|
return;
|
|
598
769
|
}
|
|
599
770
|
|
|
771
|
+
// 认证通过后允许补注册(避免 master 重启后丢失在线表)
|
|
772
|
+
this.#ensureSlaveOnline(identityText, identity, {
|
|
773
|
+
touch: true,
|
|
774
|
+
signKey: keys.signKey,
|
|
775
|
+
encryptKey: keys.encryptKey,
|
|
776
|
+
});
|
|
777
|
+
|
|
600
778
|
if (this.encrypted) {
|
|
601
779
|
try {
|
|
602
780
|
finalFrames = this.#openPayloadFrames(
|
|
@@ -604,6 +782,7 @@ export class ZNL extends EventEmitter {
|
|
|
604
782
|
parsed.requestId,
|
|
605
783
|
parsed.payloadFrames,
|
|
606
784
|
identityText,
|
|
785
|
+
keys.encryptKey,
|
|
607
786
|
);
|
|
608
787
|
} catch (error) {
|
|
609
788
|
this.#emitAuthFailed(
|
|
@@ -613,6 +792,9 @@ export class ZNL extends EventEmitter {
|
|
|
613
792
|
return;
|
|
614
793
|
}
|
|
615
794
|
}
|
|
795
|
+
} else {
|
|
796
|
+
// 明文模式同样补注册
|
|
797
|
+
this.#ensureSlaveOnline(identityText, identity, { touch: true });
|
|
616
798
|
}
|
|
617
799
|
|
|
618
800
|
const finalPayload = payloadFromFrames(finalFrames);
|
|
@@ -635,18 +817,35 @@ export class ZNL extends EventEmitter {
|
|
|
635
817
|
let finalFrames = parsed.payloadFrames;
|
|
636
818
|
|
|
637
819
|
if (this.#secureEnabled) {
|
|
820
|
+
const keys = this.#resolveSlaveKeys(identityText);
|
|
821
|
+
if (!keys) {
|
|
822
|
+
this.#emitAuthFailed(event, "未配置该 slave 的 authKey。");
|
|
823
|
+
if (this.#slaves.delete(identityText)) {
|
|
824
|
+
this.emit("slave_disconnected", identityText);
|
|
825
|
+
}
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
638
829
|
const v = this.#verifyIncomingProof({
|
|
639
830
|
kind: "response",
|
|
640
831
|
proofToken: parsed.authProof,
|
|
641
832
|
requestId: parsed.requestId,
|
|
642
833
|
payloadFrames: parsed.payloadFrames,
|
|
643
834
|
expectedNodeId: identityText,
|
|
835
|
+
signKey: keys.signKey,
|
|
644
836
|
});
|
|
645
837
|
if (!v.ok) {
|
|
646
838
|
this.#emitAuthFailed(event, v.error);
|
|
647
839
|
return;
|
|
648
840
|
}
|
|
649
841
|
|
|
842
|
+
// 认证通过后允许补注册(避免 master 重启后丢失在线表)
|
|
843
|
+
this.#ensureSlaveOnline(identityText, identity, {
|
|
844
|
+
touch: true,
|
|
845
|
+
signKey: keys.signKey,
|
|
846
|
+
encryptKey: keys.encryptKey,
|
|
847
|
+
});
|
|
848
|
+
|
|
650
849
|
if (this.encrypted) {
|
|
651
850
|
try {
|
|
652
851
|
finalFrames = this.#openPayloadFrames(
|
|
@@ -654,6 +853,7 @@ export class ZNL extends EventEmitter {
|
|
|
654
853
|
parsed.requestId,
|
|
655
854
|
parsed.payloadFrames,
|
|
656
855
|
identityText,
|
|
856
|
+
keys.encryptKey,
|
|
657
857
|
);
|
|
658
858
|
} catch (error) {
|
|
659
859
|
this.#emitAuthFailed(
|
|
@@ -663,6 +863,9 @@ export class ZNL extends EventEmitter {
|
|
|
663
863
|
return;
|
|
664
864
|
}
|
|
665
865
|
}
|
|
866
|
+
} else {
|
|
867
|
+
// 明文模式同样补注册
|
|
868
|
+
this.#ensureSlaveOnline(identityText, identity, { touch: true });
|
|
666
869
|
}
|
|
667
870
|
|
|
668
871
|
const finalPayload = payloadFromFrames(finalFrames);
|
|
@@ -893,13 +1096,21 @@ export class ZNL extends EventEmitter {
|
|
|
893
1096
|
identityText,
|
|
894
1097
|
);
|
|
895
1098
|
|
|
1099
|
+
const keys = this.#secureEnabled
|
|
1100
|
+
? this.#resolveSlaveKeys(identityText)
|
|
1101
|
+
: null;
|
|
1102
|
+
if (this.#secureEnabled && !keys) {
|
|
1103
|
+
throw new Error(`未配置该 slave 的 authKey:${identityText}`);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
896
1106
|
const payloadFrames = this.#sealPayloadFrames(
|
|
897
1107
|
"request",
|
|
898
1108
|
requestId,
|
|
899
1109
|
payload,
|
|
1110
|
+
keys?.encryptKey ?? null,
|
|
900
1111
|
);
|
|
901
1112
|
const proofOrAuthKey = this.#secureEnabled
|
|
902
|
-
? this.#createAuthProof("request", requestId, payloadFrames)
|
|
1113
|
+
? this.#createAuthProof("request", requestId, payloadFrames, keys.signKey)
|
|
903
1114
|
: "";
|
|
904
1115
|
|
|
905
1116
|
const frames = buildRequestFrames(requestId, payloadFrames, proofOrAuthKey);
|
|
@@ -947,15 +1158,29 @@ export class ZNL extends EventEmitter {
|
|
|
947
1158
|
*/
|
|
948
1159
|
async #replyTo(identity, requestId, payload) {
|
|
949
1160
|
const socket = this.#requireSocket("router", "ROUTER");
|
|
1161
|
+
const identityText = identityToString(identity);
|
|
950
1162
|
const idFrame = identityToBuffer(identity);
|
|
951
1163
|
|
|
1164
|
+
const keys = this.#secureEnabled
|
|
1165
|
+
? this.#resolveSlaveKeys(identityText)
|
|
1166
|
+
: null;
|
|
1167
|
+
if (this.#secureEnabled && !keys) {
|
|
1168
|
+
throw new Error(`未配置该 slave 的 authKey:${identityText}`);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
952
1171
|
const payloadFrames = this.#sealPayloadFrames(
|
|
953
1172
|
"response",
|
|
954
1173
|
requestId,
|
|
955
1174
|
payload,
|
|
1175
|
+
keys?.encryptKey ?? null,
|
|
956
1176
|
);
|
|
957
1177
|
const authProof = this.#secureEnabled
|
|
958
|
-
? this.#createAuthProof(
|
|
1178
|
+
? this.#createAuthProof(
|
|
1179
|
+
"response",
|
|
1180
|
+
requestId,
|
|
1181
|
+
payloadFrames,
|
|
1182
|
+
keys.signKey,
|
|
1183
|
+
)
|
|
959
1184
|
: "";
|
|
960
1185
|
|
|
961
1186
|
const frames = buildResponseFrames(requestId, payloadFrames, authProof);
|
|
@@ -998,7 +1223,11 @@ export class ZNL extends EventEmitter {
|
|
|
998
1223
|
#startHeartbeatCheck() {
|
|
999
1224
|
if (this.#heartbeatInterval <= 0) return;
|
|
1000
1225
|
|
|
1001
|
-
|
|
1226
|
+
// 心跳超时:优先使用配置值,<=0 则回退到 interval × 3
|
|
1227
|
+
const timeout =
|
|
1228
|
+
this.#heartbeatTimeoutMs > 0
|
|
1229
|
+
? this.#heartbeatTimeoutMs
|
|
1230
|
+
: this.#heartbeatInterval * 3;
|
|
1002
1231
|
|
|
1003
1232
|
this.#heartbeatCheckTimer = setInterval(() => {
|
|
1004
1233
|
const now = Date.now();
|
|
@@ -1023,8 +1252,9 @@ export class ZNL extends EventEmitter {
|
|
|
1023
1252
|
* @param {Buffer[]} payloadFrames
|
|
1024
1253
|
* @returns {string}
|
|
1025
1254
|
*/
|
|
1026
|
-
#createAuthProof(kind, requestId, payloadFrames) {
|
|
1027
|
-
|
|
1255
|
+
#createAuthProof(kind, requestId, payloadFrames, signKeyOverride = null) {
|
|
1256
|
+
const signKey = signKeyOverride ?? this.#signKey;
|
|
1257
|
+
if (!this.#secureEnabled || !signKey) return "";
|
|
1028
1258
|
|
|
1029
1259
|
const envelope = {
|
|
1030
1260
|
kind: String(kind),
|
|
@@ -1032,13 +1262,16 @@ export class ZNL extends EventEmitter {
|
|
|
1032
1262
|
requestId: String(requestId ?? ""),
|
|
1033
1263
|
timestamp: nowMs(),
|
|
1034
1264
|
nonce: generateNonce(),
|
|
1035
|
-
|
|
1265
|
+
// 可选 payload 摘要:关闭时写空串以提升性能
|
|
1266
|
+
payloadDigest: this.#enablePayloadDigest
|
|
1267
|
+
? digestFrames(payloadFrames)
|
|
1268
|
+
: "",
|
|
1036
1269
|
};
|
|
1037
1270
|
|
|
1038
1271
|
// 保留 canonical 文本,便于后续排障(签名实际在 token 内完成)
|
|
1039
1272
|
canonicalSignInput(envelope);
|
|
1040
1273
|
|
|
1041
|
-
return encodeAuthProofToken(
|
|
1274
|
+
return encodeAuthProofToken(signKey, envelope);
|
|
1042
1275
|
}
|
|
1043
1276
|
|
|
1044
1277
|
/**
|
|
@@ -1059,9 +1292,12 @@ export class ZNL extends EventEmitter {
|
|
|
1059
1292
|
requestId,
|
|
1060
1293
|
payloadFrames,
|
|
1061
1294
|
expectedNodeId = null,
|
|
1295
|
+
signKey = null,
|
|
1062
1296
|
}) {
|
|
1063
1297
|
if (!this.#secureEnabled) return { ok: true, envelope: null };
|
|
1064
|
-
|
|
1298
|
+
|
|
1299
|
+
const verifyKey = signKey ?? this.#signKey;
|
|
1300
|
+
if (!verifyKey) {
|
|
1065
1301
|
return { ok: false, error: "签名密钥未初始化。" };
|
|
1066
1302
|
}
|
|
1067
1303
|
|
|
@@ -1069,7 +1305,7 @@ export class ZNL extends EventEmitter {
|
|
|
1069
1305
|
return { ok: false, error: "缺少认证证明(proofToken)。" };
|
|
1070
1306
|
}
|
|
1071
1307
|
|
|
1072
|
-
const decoded = decodeAuthProofToken(
|
|
1308
|
+
const decoded = decodeAuthProofToken(verifyKey, proofToken, {
|
|
1073
1309
|
maxSkewMs: this.#maxTimeSkewMs,
|
|
1074
1310
|
now: Date.now(),
|
|
1075
1311
|
});
|
|
@@ -1100,12 +1336,15 @@ export class ZNL extends EventEmitter {
|
|
|
1100
1336
|
};
|
|
1101
1337
|
}
|
|
1102
1338
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1339
|
+
if (this.#enablePayloadDigest) {
|
|
1340
|
+
const currentDigest = digestFrames(payloadFrames);
|
|
1341
|
+
if (String(envelope.payloadDigest) !== currentDigest) {
|
|
1342
|
+
return { ok: false, error: "payload 摘要不一致,疑似篡改。" };
|
|
1343
|
+
}
|
|
1106
1344
|
}
|
|
1107
1345
|
|
|
1108
|
-
|
|
1346
|
+
// 重放 key 加入 requestId,降低 nonce 碰撞误杀风险
|
|
1347
|
+
const replayKey = `${envelope.kind}|${envelope.nodeId}|${envelope.requestId}|${envelope.nonce}`;
|
|
1109
1348
|
if (this.#replayGuard.seenOrAdd(replayKey)) {
|
|
1110
1349
|
return { ok: false, error: "检测到重放请求(nonce 重复)。" };
|
|
1111
1350
|
}
|
|
@@ -1123,13 +1362,14 @@ export class ZNL extends EventEmitter {
|
|
|
1123
1362
|
* @param {*} payload
|
|
1124
1363
|
* @returns {Buffer[]}
|
|
1125
1364
|
*/
|
|
1126
|
-
#sealPayloadFrames(kind, requestId, payload) {
|
|
1365
|
+
#sealPayloadFrames(kind, requestId, payload, encryptKeyOverride = null) {
|
|
1127
1366
|
if (!this.encrypted) {
|
|
1128
1367
|
// 非加密模式保持历史行为:沿用协议层的原始帧类型(string/Buffer)
|
|
1129
1368
|
return normalizeFrames(payload);
|
|
1130
1369
|
}
|
|
1131
1370
|
|
|
1132
|
-
|
|
1371
|
+
const encryptKey = encryptKeyOverride ?? this.#encryptKey;
|
|
1372
|
+
if (!encryptKey) {
|
|
1133
1373
|
throw new Error("加密密钥未初始化。");
|
|
1134
1374
|
}
|
|
1135
1375
|
|
|
@@ -1139,11 +1379,7 @@ export class ZNL extends EventEmitter {
|
|
|
1139
1379
|
"utf8",
|
|
1140
1380
|
);
|
|
1141
1381
|
|
|
1142
|
-
const { iv, ciphertext, tag } = encryptFrames(
|
|
1143
|
-
this.#encryptKey,
|
|
1144
|
-
rawFrames,
|
|
1145
|
-
aad,
|
|
1146
|
-
);
|
|
1382
|
+
const { iv, ciphertext, tag } = encryptFrames(encryptKey, rawFrames, aad);
|
|
1147
1383
|
|
|
1148
1384
|
// 用统一信封包装:version + iv + tag + ciphertext
|
|
1149
1385
|
return [Buffer.from(SECURITY_ENVELOPE_VERSION), iv, tag, ciphertext];
|
|
@@ -1160,9 +1396,16 @@ export class ZNL extends EventEmitter {
|
|
|
1160
1396
|
* @param {string} senderNodeId
|
|
1161
1397
|
* @returns {Buffer[]}
|
|
1162
1398
|
*/
|
|
1163
|
-
#openPayloadFrames(
|
|
1399
|
+
#openPayloadFrames(
|
|
1400
|
+
kind,
|
|
1401
|
+
requestId,
|
|
1402
|
+
payloadFrames,
|
|
1403
|
+
senderNodeId,
|
|
1404
|
+
encryptKeyOverride = null,
|
|
1405
|
+
) {
|
|
1164
1406
|
if (!this.encrypted) return payloadFrames;
|
|
1165
|
-
|
|
1407
|
+
const encryptKey = encryptKeyOverride ?? this.#encryptKey;
|
|
1408
|
+
if (!encryptKey) throw new Error("加密密钥未初始化。");
|
|
1166
1409
|
|
|
1167
1410
|
if (!Array.isArray(payloadFrames) || payloadFrames.length !== 4) {
|
|
1168
1411
|
throw new Error("加密信封格式非法:期望 4 帧。");
|
|
@@ -1178,7 +1421,7 @@ export class ZNL extends EventEmitter {
|
|
|
1178
1421
|
"utf8",
|
|
1179
1422
|
);
|
|
1180
1423
|
|
|
1181
|
-
return decryptFrames(
|
|
1424
|
+
return decryptFrames(encryptKey, iv, ciphertext, tag, aad);
|
|
1182
1425
|
}
|
|
1183
1426
|
|
|
1184
1427
|
/**
|
|
@@ -1194,6 +1437,76 @@ export class ZNL extends EventEmitter {
|
|
|
1194
1437
|
});
|
|
1195
1438
|
}
|
|
1196
1439
|
|
|
1440
|
+
/**
|
|
1441
|
+
* master 侧确保某个 slave 已登记在线
|
|
1442
|
+
* - 仅在认证通过后调用
|
|
1443
|
+
* - 支持心跳/请求/响应等路径的自动补注册
|
|
1444
|
+
*
|
|
1445
|
+
* @param {string} identityText
|
|
1446
|
+
* @param {Buffer|string|Uint8Array} identity
|
|
1447
|
+
* @param {{ touch?: boolean }} [options]
|
|
1448
|
+
*/
|
|
1449
|
+
#ensureSlaveOnline(
|
|
1450
|
+
identityText,
|
|
1451
|
+
identity,
|
|
1452
|
+
{ touch = true, signKey = null, encryptKey = null } = {},
|
|
1453
|
+
) {
|
|
1454
|
+
const id = String(identityText ?? "");
|
|
1455
|
+
if (!id) return;
|
|
1456
|
+
|
|
1457
|
+
const now = Date.now();
|
|
1458
|
+
const entry = this.#slaves.get(id);
|
|
1459
|
+
if (entry) {
|
|
1460
|
+
if (touch) entry.lastSeen = now;
|
|
1461
|
+
if (signKey) entry.signKey = signKey;
|
|
1462
|
+
if (encryptKey) entry.encryptKey = encryptKey;
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
this.#slaves.set(id, {
|
|
1467
|
+
identity: identityToBuffer(identity),
|
|
1468
|
+
lastSeen: now,
|
|
1469
|
+
signKey: signKey ?? null,
|
|
1470
|
+
encryptKey: encryptKey ?? null,
|
|
1471
|
+
});
|
|
1472
|
+
this.emit("slave_connected", id);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* master 侧解析某个 slave 的签名/加密密钥
|
|
1477
|
+
* - 优先匹配 authKeyMap
|
|
1478
|
+
* - 未命中时回退到 this.authKey(若提供)
|
|
1479
|
+
*
|
|
1480
|
+
* @param {string} slaveId
|
|
1481
|
+
* @returns {{ signKey: Buffer, encryptKey: Buffer }|null}
|
|
1482
|
+
*/
|
|
1483
|
+
#resolveSlaveKeys(slaveId) {
|
|
1484
|
+
if (!this.#secureEnabled) return null;
|
|
1485
|
+
|
|
1486
|
+
const id = String(slaveId ?? "");
|
|
1487
|
+
if (!id) return null;
|
|
1488
|
+
|
|
1489
|
+
const cached = this.#slaveKeyCache.get(id);
|
|
1490
|
+
if (cached) return cached;
|
|
1491
|
+
|
|
1492
|
+
let key = this.#authKeyMap.get(id);
|
|
1493
|
+
if (!key && this.authKey) {
|
|
1494
|
+
key = this.authKey;
|
|
1495
|
+
}
|
|
1496
|
+
if (!key) return null;
|
|
1497
|
+
|
|
1498
|
+
const derived = deriveKeys(key);
|
|
1499
|
+
this.#slaveKeyCache.set(id, derived);
|
|
1500
|
+
|
|
1501
|
+
const entry = this.#slaves.get(id);
|
|
1502
|
+
if (entry) {
|
|
1503
|
+
entry.signKey = derived.signKey;
|
|
1504
|
+
entry.encryptKey = derived.encryptKey;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
return derived;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1197
1510
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1198
1511
|
// 工具方法
|
|
1199
1512
|
// ═══════════════════════════════════════════════════════════════════════════
|
package/src/constants.js
CHANGED
|
@@ -36,6 +36,15 @@ export const DEFAULT_TIMEOUT_MS = 5000;
|
|
|
36
36
|
/** 默认心跳间隔(毫秒),0 表示禁用心跳 */
|
|
37
37
|
export const DEFAULT_HEARTBEAT_INTERVAL = 3000;
|
|
38
38
|
|
|
39
|
+
/** 默认心跳超时时间(毫秒),0 表示采用 heartbeatInterval × 3 */
|
|
40
|
+
export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 0;
|
|
41
|
+
|
|
42
|
+
/** 默认最大并发请求数(0 表示不限制) */
|
|
43
|
+
export const DEFAULT_MAX_PENDING = 1000;
|
|
44
|
+
|
|
45
|
+
/** 默认是否启用 payload 摘要(安全模式用) */
|
|
46
|
+
export const DEFAULT_ENABLE_PAYLOAD_DIGEST = true;
|
|
47
|
+
|
|
39
48
|
/** 默认端点配置 */
|
|
40
49
|
export const DEFAULT_ENDPOINTS = {
|
|
41
50
|
router: "tcp://127.0.0.1:6003",
|
package/src/security.js
CHANGED
|
@@ -385,11 +385,29 @@ export function decodeAuthProofToken(
|
|
|
385
385
|
|
|
386
386
|
/**
|
|
387
387
|
* 计算 payload 帧摘要(sha256 hex)
|
|
388
|
+
* 说明:按协议顺序增量写入,避免一次性拼接大 Buffer
|
|
388
389
|
* @param {Buffer[]} frames
|
|
389
390
|
* @returns {string}
|
|
390
391
|
*/
|
|
391
392
|
export function digestFrames(frames) {
|
|
392
|
-
|
|
393
|
+
const safeFrames = Array.isArray(frames) ? frames : [];
|
|
394
|
+
const hash = createHash("sha256");
|
|
395
|
+
|
|
396
|
+
// 写入帧数量(u32be)
|
|
397
|
+
const head = Buffer.allocUnsafe(4);
|
|
398
|
+
head.writeUInt32BE(safeFrames.length, 0);
|
|
399
|
+
hash.update(head);
|
|
400
|
+
|
|
401
|
+
// 逐帧写入长度与内容(u32be + bytes)
|
|
402
|
+
for (const frame of safeFrames) {
|
|
403
|
+
const buf = toBuffer(frame);
|
|
404
|
+
const len = Buffer.allocUnsafe(4);
|
|
405
|
+
len.writeUInt32BE(buf.length, 0);
|
|
406
|
+
hash.update(len);
|
|
407
|
+
if (buf.length > 0) hash.update(buf);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return hash.digest("hex");
|
|
393
411
|
}
|
|
394
412
|
|
|
395
413
|
/**
|