clawmatrix 0.1.22 → 0.2.0
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 +4 -1
- package/package.json +4 -2
- package/src/acp-proxy.ts +2073 -0
- package/src/audit.ts +42 -0
- package/src/auth.ts +2 -3
- package/src/cli.ts +76 -2
- package/src/cluster-service.ts +243 -3
- package/src/compat.ts +84 -3
- package/src/config.ts +117 -4
- package/src/connection.ts +290 -85
- package/src/crypto.ts +179 -0
- package/src/debug.ts +15 -2
- package/src/e2e/helpers.ts +318 -0
- package/src/handoff.ts +132 -87
- package/src/identity.ts +95 -0
- package/src/index.ts +539 -45
- package/src/knowledge-sync.ts +777 -205
- package/src/local-tools.ts +9 -2
- package/src/model-proxy.ts +358 -110
- package/src/peer-approval.ts +628 -0
- package/src/peer-manager.ts +270 -38
- package/src/rate-limiter.ts +88 -0
- package/src/router.ts +32 -10
- package/src/sentinel-manager.ts +142 -0
- package/src/sentinel.ts +618 -0
- package/src/task-activity.ts +74 -0
- package/src/terminal.ts +566 -0
- package/src/tool-proxy.ts +127 -3
- package/src/tools/cluster-acp.ts +237 -0
- package/src/tools/cluster-batch.ts +76 -0
- package/src/tools/cluster-diagnostic.ts +174 -0
- package/src/tools/cluster-edit.ts +70 -0
- package/src/tools/cluster-peers.ts +59 -14
- package/src/tools/cluster-terminal.ts +232 -0
- package/src/tools/cluster-tool.ts +26 -11
- package/src/types.ts +477 -3
- package/src/web.ts +2 -2
package/src/peer-manager.ts
CHANGED
|
@@ -1,27 +1,46 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
2
4
|
import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
|
|
3
5
|
import { WebSocketServer, WebSocket as WsWebSocket } from "ws";
|
|
4
6
|
import type { ClawMatrixConfig, PeerConfig } from "./config.ts";
|
|
5
7
|
import { Connection } from "./connection.ts";
|
|
6
|
-
import type { WsTransport } from "./connection.ts";
|
|
8
|
+
import type { WsTransport, ConnectionE2eeOptions } from "./connection.ts";
|
|
7
9
|
import { Router } from "./router.ts";
|
|
10
|
+
import { debug } from "./debug.ts";
|
|
8
11
|
import { collectDeviceInfo } from "./device-info.ts";
|
|
12
|
+
import { RateLimiter } from "./rate-limiter.ts";
|
|
13
|
+
import { audit } from "./audit.ts";
|
|
9
14
|
import type {
|
|
10
15
|
AnyClusterFrame,
|
|
11
16
|
ClusterFrame,
|
|
12
17
|
DeviceInfo,
|
|
13
18
|
NodeCapabilities,
|
|
19
|
+
PeerApprovalRequest,
|
|
20
|
+
PeerApprovalResponse,
|
|
14
21
|
PeerInfo,
|
|
15
22
|
PeerSync,
|
|
16
23
|
} from "./types.ts";
|
|
24
|
+
import { PeerApprovalManager, type ChannelApi, type NotifyTarget } from "./peer-approval.ts";
|
|
25
|
+
import { loadOrCreateIdentity } from "./identity.ts";
|
|
26
|
+
import type { KeyPair } from "./crypto.ts";
|
|
17
27
|
|
|
18
28
|
const RECONNECT_BASE = 1_000;
|
|
19
29
|
const RECONNECT_MAX = 60_000;
|
|
20
30
|
|
|
31
|
+
/** Check if an IP is a loopback address (IPv4 127.x or IPv6 ::1). */
|
|
32
|
+
function isLoopback(ip?: string): boolean {
|
|
33
|
+
if (!ip) return false;
|
|
34
|
+
// Normalize IPv6-mapped IPv4 (e.g. "::ffff:127.0.0.1")
|
|
35
|
+
const normalized = ip.replace(/^::ffff:/, "");
|
|
36
|
+
return normalized === "::1" || normalized.startsWith("127.");
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
export interface PeerManagerEvents {
|
|
22
40
|
frame: [frame: AnyClusterFrame, from: Connection];
|
|
23
41
|
peerConnected: [nodeId: string];
|
|
24
42
|
peerDisconnected: [nodeId: string];
|
|
43
|
+
peerCapabilitiesChanged: [];
|
|
25
44
|
}
|
|
26
45
|
|
|
27
46
|
export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
@@ -37,12 +56,21 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
37
56
|
private stopped = false;
|
|
38
57
|
/** Map from ws WebSocket to Connection for inbound connections. */
|
|
39
58
|
private inboundConnections = new Map<WsWebSocket, Connection>();
|
|
59
|
+
/** Map from ws WebSocket to remote IP (for audit logging on close). */
|
|
60
|
+
private inboundIps = new Map<WsWebSocket, string>();
|
|
40
61
|
private gossipDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
62
|
+
/** Persistent X25519 identity key pair (TOFU). See identity.ts for security model. */
|
|
63
|
+
private identityKeyPair: KeyPair;
|
|
64
|
+
private e2eeOptions: ConnectionE2eeOptions;
|
|
65
|
+
private e2eeOptionsInbound: ConnectionE2eeOptions;
|
|
66
|
+
private rateLimiter: RateLimiter;
|
|
67
|
+
readonly approvalManager: PeerApprovalManager;
|
|
41
68
|
|
|
42
69
|
constructor(config: ClawMatrixConfig, openclawVersion?: string) {
|
|
43
70
|
super();
|
|
44
71
|
this.config = config;
|
|
45
72
|
this.localDeviceInfo = collectDeviceInfo(openclawVersion);
|
|
73
|
+
const acpAgents = config.acp?.enabled ? config.acp.agents : undefined;
|
|
46
74
|
this.localCapabilities = {
|
|
47
75
|
nodeId: config.nodeId,
|
|
48
76
|
agents: config.agents,
|
|
@@ -54,18 +82,44 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
54
82
|
allow: config.toolProxy.allow,
|
|
55
83
|
deny: config.toolProxy.deny,
|
|
56
84
|
} : undefined,
|
|
85
|
+
acpAgents,
|
|
57
86
|
};
|
|
87
|
+
// Load or generate persistent identity key pair (TOFU — see identity.ts)
|
|
88
|
+
// Use ~/.openclaw/ instead of cwd — gateway's cwd may be "/" (read-only)
|
|
89
|
+
const stateDir = path.join(homedir() || tmpdir(), ".openclaw", "clawmatrix");
|
|
90
|
+
this.identityKeyPair = loadOrCreateIdentity(stateDir);
|
|
91
|
+
|
|
92
|
+
this.e2eeOptions = {
|
|
93
|
+
e2ee: config.e2ee, compression: config.compression,
|
|
94
|
+
identityKeyPair: config.e2ee !== false ? this.identityKeyPair : undefined,
|
|
95
|
+
};
|
|
96
|
+
// Inbound connections defer auth_ok when peer approval is enabled in required mode
|
|
97
|
+
const deferAuth = config.peerApproval?.enabled && config.peerApproval?.mode === "required";
|
|
98
|
+
this.e2eeOptionsInbound = { ...this.e2eeOptions, deferAuthOk: !!deferAuth };
|
|
99
|
+
this.rateLimiter = new RateLimiter();
|
|
58
100
|
this.router = new Router(config.nodeId, {
|
|
59
101
|
agents: config.agents,
|
|
60
102
|
models: config.models,
|
|
61
103
|
tags: config.tags,
|
|
62
104
|
deviceInfo: this.localDeviceInfo,
|
|
63
105
|
toolProxy: this.localCapabilities.toolProxy,
|
|
106
|
+
acpAgents,
|
|
64
107
|
});
|
|
108
|
+
|
|
109
|
+
// Peer approval
|
|
110
|
+
const approvalConfig = config.peerApproval ?? {
|
|
111
|
+
enabled: false, mode: "notify" as const, timeout: 1_200_000, allowList: [], persistPath: "approved-peers.json",
|
|
112
|
+
};
|
|
113
|
+
this.approvalManager = new PeerApprovalManager(approvalConfig, stateDir);
|
|
65
114
|
}
|
|
66
115
|
|
|
67
116
|
// ── Lifecycle ──────────────────────────────────────────────────
|
|
68
117
|
async start() {
|
|
118
|
+
await this.approvalManager.load();
|
|
119
|
+
this.approvalManager.setBroadcastFn((frame) => {
|
|
120
|
+
frame.from = this.config.nodeId;
|
|
121
|
+
this.router.broadcast(frame);
|
|
122
|
+
});
|
|
69
123
|
if (this.config.listen) {
|
|
70
124
|
this.startListening();
|
|
71
125
|
}
|
|
@@ -105,6 +159,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
105
159
|
this.httpServer = null;
|
|
106
160
|
}
|
|
107
161
|
|
|
162
|
+
this.rateLimiter.destroy();
|
|
163
|
+
this.approvalManager.destroy();
|
|
108
164
|
this.router.destroy();
|
|
109
165
|
}
|
|
110
166
|
|
|
@@ -122,14 +178,35 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
122
178
|
|
|
123
179
|
this.httpServer = createServer((req, res) => {
|
|
124
180
|
if (this.httpRequestHandler && this.httpRequestHandler(req, res)) return;
|
|
125
|
-
|
|
126
|
-
res.
|
|
181
|
+
// Disguise as a normal web server to avoid fingerprinting
|
|
182
|
+
res.writeHead(200, { "Content-Type": "text/html", "Server": "nginx" });
|
|
183
|
+
res.end("<!DOCTYPE html><html><head><title>Welcome</title></head><body><p>It works!</p></body></html>");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
this.wss = new WebSocketServer({
|
|
187
|
+
server: this.httpServer,
|
|
188
|
+
handleProtocols(protocols) {
|
|
189
|
+
// Accept client's preferred subprotocol for disguise
|
|
190
|
+
if (protocols.size > 0) return protocols.values().next().value!;
|
|
191
|
+
return false;
|
|
192
|
+
},
|
|
127
193
|
});
|
|
128
194
|
|
|
129
|
-
this.wss
|
|
195
|
+
this.wss.on("connection", (ws, req) => {
|
|
196
|
+
const ip = req.headers["x-forwarded-for"]?.toString().split(",")[0].trim()
|
|
197
|
+
?? req.socket.remoteAddress
|
|
198
|
+
?? "unknown";
|
|
199
|
+
|
|
200
|
+
// Rate limit check
|
|
201
|
+
if (!this.rateLimiter.check(ip)) {
|
|
202
|
+
audit("conn_rate_limited", { ip, detail: `remaining=0` });
|
|
203
|
+
ws.close(4029, "rate limited");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
130
206
|
|
|
131
|
-
|
|
132
|
-
this.
|
|
207
|
+
audit("conn_open", { ip });
|
|
208
|
+
this.inboundIps.set(ws, ip);
|
|
209
|
+
this.handleInboundOpen(ws, ip);
|
|
133
210
|
|
|
134
211
|
ws.on("message", (data) => {
|
|
135
212
|
const conn = this.inboundConnections.get(ws);
|
|
@@ -144,6 +221,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
144
221
|
conn.feedClose(code, reason.toString());
|
|
145
222
|
this.inboundConnections.delete(ws);
|
|
146
223
|
}
|
|
224
|
+
this.inboundIps.delete(ws);
|
|
147
225
|
});
|
|
148
226
|
});
|
|
149
227
|
|
|
@@ -154,7 +232,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
154
232
|
this.httpServer.listen(port, hostname);
|
|
155
233
|
}
|
|
156
234
|
|
|
157
|
-
private handleInboundOpen(ws: WsWebSocket) {
|
|
235
|
+
private handleInboundOpen(ws: WsWebSocket, ip: string) {
|
|
158
236
|
// Wrap ws WebSocket into our WsTransport interface
|
|
159
237
|
const transport: WsTransport = {
|
|
160
238
|
send(data: string) {
|
|
@@ -174,12 +252,25 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
174
252
|
this.config.nodeId,
|
|
175
253
|
this.config.secret,
|
|
176
254
|
this.localCapabilities,
|
|
255
|
+
this.e2eeOptionsInbound,
|
|
177
256
|
);
|
|
178
257
|
|
|
179
258
|
this.inboundConnections.set(ws, conn);
|
|
180
259
|
|
|
181
260
|
conn.on("authenticated", (caps) => {
|
|
182
|
-
|
|
261
|
+
audit("auth_success", { ip, nodeId: caps.nodeId });
|
|
262
|
+
this.onPeerAuthenticated(conn, caps, ip);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
conn.on("close", (code, reason) => {
|
|
266
|
+
if (!conn.authenticated && code === 4001) {
|
|
267
|
+
audit("auth_failure", { ip, detail: reason });
|
|
268
|
+
this.rateLimiter.recordFailure(ip);
|
|
269
|
+
} else if (!conn.authenticated && code === 4003) {
|
|
270
|
+
audit("auth_timeout", { ip });
|
|
271
|
+
} else {
|
|
272
|
+
audit("conn_close", { ip, nodeId: conn.remoteNodeId ?? undefined, detail: `code=${code}` });
|
|
273
|
+
}
|
|
183
274
|
});
|
|
184
275
|
|
|
185
276
|
conn.on("error", () => {
|
|
@@ -191,7 +282,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
191
282
|
private connectToPeer(peer: PeerConfig) {
|
|
192
283
|
if (this.stopped) return;
|
|
193
284
|
|
|
194
|
-
|
|
285
|
+
// Use a common WS subprotocol for traffic disguise
|
|
286
|
+
const ws = new WebSocket(peer.url, ["graphql-transport-ws"]);
|
|
195
287
|
|
|
196
288
|
ws.addEventListener("open", () => {
|
|
197
289
|
const conn = new Connection(
|
|
@@ -200,6 +292,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
200
292
|
this.config.nodeId,
|
|
201
293
|
this.config.secret,
|
|
202
294
|
this.localCapabilities,
|
|
295
|
+
this.e2eeOptions,
|
|
203
296
|
);
|
|
204
297
|
conn.bindWebSocket(ws);
|
|
205
298
|
|
|
@@ -213,13 +306,16 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
213
306
|
});
|
|
214
307
|
});
|
|
215
308
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
309
|
+
let reconnectScheduled = false;
|
|
310
|
+
const tryReconnect = () => {
|
|
311
|
+
if (!reconnectScheduled) {
|
|
312
|
+
reconnectScheduled = true;
|
|
313
|
+
this.scheduleReconnect(peer);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
219
316
|
|
|
220
|
-
ws.addEventListener("
|
|
221
|
-
|
|
222
|
-
});
|
|
317
|
+
ws.addEventListener("error", tryReconnect);
|
|
318
|
+
ws.addEventListener("close", tryReconnect);
|
|
223
319
|
}
|
|
224
320
|
|
|
225
321
|
private scheduleReconnect(peer: PeerConfig) {
|
|
@@ -238,8 +334,76 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
238
334
|
}
|
|
239
335
|
|
|
240
336
|
// ── Peer lifecycle ─────────────────────────────────────────────
|
|
241
|
-
private onPeerAuthenticated(conn: Connection, caps: NodeCapabilities) {
|
|
337
|
+
private onPeerAuthenticated(conn: Connection, caps: NodeCapabilities, ip?: string) {
|
|
242
338
|
const nodeId = conn.remoteNodeId!;
|
|
339
|
+
// Peer's persistent public key for TOFU identity binding
|
|
340
|
+
const peerPublicKey = conn.remoteIdentityKey ?? undefined;
|
|
341
|
+
|
|
342
|
+
// Peer approval check (inbound only — outbound peers are explicitly configured)
|
|
343
|
+
// Skip approval for same-nodeId connections from localhost (local clients
|
|
344
|
+
// like Mac desktop app / iOS simulator). An attacker would need to already
|
|
345
|
+
// be on the same machine to exploit this, which is outside our threat model.
|
|
346
|
+
const isLocalClient = nodeId === this.config.nodeId && isLoopback(ip);
|
|
347
|
+
debug("approval", `onPeerAuthenticated: nodeId=${nodeId} role=${conn.role} isLocalClient=${isLocalClient} ip=${ip}`);
|
|
348
|
+
if (conn.role === "inbound" && !isLocalClient) {
|
|
349
|
+
// IP-level approval rate limiting (suppress noise from leaked tokens)
|
|
350
|
+
if (ip && this.approvalManager.isIpBlocked(ip)) {
|
|
351
|
+
audit("approval_ip_blocked", { ip, nodeId });
|
|
352
|
+
conn.close(4005, "approval denied");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const check = this.approvalManager.checkPeer(nodeId, caps, peerPublicKey);
|
|
357
|
+
debug("approval", `checkPeer: nodeId=${nodeId} result=${check}`);
|
|
358
|
+
if (check === "deny") {
|
|
359
|
+
conn.close(4005, "approval denied");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (check === "pending") {
|
|
363
|
+
// In notify mode, requestApproval auto-approves and sends notification.
|
|
364
|
+
// In required mode, it waits for explicit approval.
|
|
365
|
+
this.approvalManager.requestApproval(
|
|
366
|
+
nodeId,
|
|
367
|
+
caps,
|
|
368
|
+
(frame) => {
|
|
369
|
+
frame.from = this.config.nodeId;
|
|
370
|
+
this.router.broadcast(frame);
|
|
371
|
+
},
|
|
372
|
+
peerPublicKey,
|
|
373
|
+
ip,
|
|
374
|
+
).then((decision) => {
|
|
375
|
+
if (decision === "approve" && conn.isOpen) {
|
|
376
|
+
conn.completeAuth();
|
|
377
|
+
this.completePeerJoin(conn, caps);
|
|
378
|
+
} else if (conn.isOpen) {
|
|
379
|
+
if (ip) this.approvalManager.recordIpDeny(ip);
|
|
380
|
+
conn.close(
|
|
381
|
+
decision === "timeout" ? 4004 : 4005,
|
|
382
|
+
decision === "timeout" ? "approval timeout" : "approval denied",
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
// In required mode, don't complete the join yet
|
|
387
|
+
if (this.config.peerApproval?.mode === "required") {
|
|
388
|
+
// Wire up close handler to clean up if connection drops while pending
|
|
389
|
+
conn.on("close", () => this.onPeerDisconnected(conn));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
// In notify mode, requestApproval resolves immediately, but
|
|
393
|
+
// completePeerJoin is called async — fall through to complete now
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// For outbound connections auth_ok is not deferred; for already-approved
|
|
398
|
+
// inbound connections we need to send the deferred auth_ok now.
|
|
399
|
+
conn.completeAuth();
|
|
400
|
+
this.completePeerJoin(conn, caps);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private completePeerJoin(conn: Connection, caps: NodeCapabilities) {
|
|
404
|
+
const nodeId = conn.remoteNodeId!;
|
|
405
|
+
// Same-nodeId 连接(如 Mac/iOS 桌面客户端)也正常注册路由,
|
|
406
|
+
// 使得 sendTo(nodeId) 能将响应帧路由回客户端连接。
|
|
243
407
|
this.router.addDirectPeer(nodeId, conn, caps);
|
|
244
408
|
|
|
245
409
|
conn.on("message", (frame) => this.onFrame(frame, conn));
|
|
@@ -248,28 +412,32 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
248
412
|
|
|
249
413
|
this.sendPeerSync(conn);
|
|
250
414
|
|
|
251
|
-
//
|
|
252
|
-
this.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
415
|
+
// Same-nodeId 的本地客户端不向集群广播 peer_join,对外不可见
|
|
416
|
+
if (nodeId !== this.config.nodeId) {
|
|
417
|
+
// Broadcast peer_join to other peers
|
|
418
|
+
this.router.broadcast({
|
|
419
|
+
type: "peer_join",
|
|
420
|
+
from: this.config.nodeId,
|
|
421
|
+
timestamp: Date.now(),
|
|
422
|
+
payload: {
|
|
423
|
+
nodeId,
|
|
424
|
+
agents: caps.agents,
|
|
425
|
+
models: caps.models,
|
|
426
|
+
tags: caps.tags,
|
|
427
|
+
deviceInfo: caps.deviceInfo,
|
|
428
|
+
toolProxy: caps.toolProxy,
|
|
429
|
+
},
|
|
430
|
+
} as AnyClusterFrame);
|
|
431
|
+
|
|
432
|
+
// Re-sync with all existing peers so they learn about the new node
|
|
433
|
+
for (const existingConn of this.router.getDirectConnections()) {
|
|
434
|
+
if (existingConn !== conn && existingConn.isOpen) {
|
|
435
|
+
this.sendPeerSync(existingConn);
|
|
436
|
+
}
|
|
270
437
|
}
|
|
271
438
|
}
|
|
272
439
|
|
|
440
|
+
audit("peer_join", { nodeId, detail: `agents=${caps.agents.length} models=${caps.models.length}` });
|
|
273
441
|
this.emit("peerConnected", nodeId);
|
|
274
442
|
}
|
|
275
443
|
|
|
@@ -277,6 +445,13 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
277
445
|
const nodeId = conn.remoteNodeId;
|
|
278
446
|
if (!nodeId) return;
|
|
279
447
|
|
|
448
|
+
// Same-nodeId 本地客户端断开:仅清理路由,不广播 peer_leave
|
|
449
|
+
if (nodeId === this.config.nodeId) {
|
|
450
|
+
this.router.removePeer(nodeId);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
audit("peer_leave", { nodeId });
|
|
280
455
|
this.router.removePeer(nodeId);
|
|
281
456
|
|
|
282
457
|
// Remove satellite contexts that were only reachable via this peer
|
|
@@ -299,7 +474,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
299
474
|
// ── Message handling ───────────────────────────────────────────
|
|
300
475
|
private onFrame(frame: AnyClusterFrame, from: Connection) {
|
|
301
476
|
// Validate from field: must be the direct peer or a known node (relayed)
|
|
302
|
-
if (frame.from && frame.from !== from.remoteNodeId && !this.router.getRoute(frame.from))
|
|
477
|
+
if (frame.from && frame.from !== from.remoteNodeId && !this.router.getRoute(frame.from)) {
|
|
478
|
+
debug("peer", `dropped frame type=${frame.type} from=${frame.from}: unknown origin (via ${from.remoteNodeId})`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
303
481
|
|
|
304
482
|
// Skip dedup for streaming and response frame types.
|
|
305
483
|
// Stream frames share one id across many chunks.
|
|
@@ -315,9 +493,52 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
315
493
|
|| frame.type === "handoff_res" || frame.type === "handoff_status_res"
|
|
316
494
|
|| frame.type === "handoff_input_required"
|
|
317
495
|
|| frame.type === "handoff_input" || frame.type === "handoff_cancel"
|
|
318
|
-
|| frame.type === "handoff_status"
|
|
496
|
+
|| frame.type === "handoff_status"
|
|
497
|
+
|| frame.type === "diagnostic_exec_res" || frame.type === "diagnostic_status_res"
|
|
498
|
+
|| frame.type === "peer_approval_res"
|
|
499
|
+
|| frame.type === "acp_stream" || frame.type === "acp_res"
|
|
500
|
+
|| frame.type === "acp_close_res"
|
|
501
|
+
|| frame.type === "acp_list_res" || frame.type === "acp_resume_res"
|
|
502
|
+
|| frame.type === "acp_cancel_res"
|
|
503
|
+
|| frame.type === "acp_set_mode_res" || frame.type === "acp_get_modes_res"
|
|
504
|
+
|| frame.type === "terminal_open_res" || frame.type === "terminal_data"
|
|
505
|
+
|| frame.type === "terminal_resize" || frame.type === "terminal_close"
|
|
506
|
+
|| frame.type === "terminal_close_res";
|
|
319
507
|
if (frame.id && !skipDedup && this.router.isDuplicate(frame.id)) return;
|
|
320
508
|
|
|
509
|
+
// Handle peer approval responses locally (don't emit to cluster-service)
|
|
510
|
+
if (frame.type === "peer_approval_res") {
|
|
511
|
+
this.approvalManager.handleApprovalFrame(frame as PeerApprovalResponse);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Forward peer approval requests from other nodes to local IM channels + desktop notification
|
|
516
|
+
if (frame.type === "peer_approval_req" && frame.from !== this.config.nodeId) {
|
|
517
|
+
const req = frame as PeerApprovalRequest;
|
|
518
|
+
this.approvalManager.forwardApprovalToIM(
|
|
519
|
+
req.payload.approvalId,
|
|
520
|
+
req.payload.nodeId,
|
|
521
|
+
req.payload.deviceInfo,
|
|
522
|
+
req.payload.ip,
|
|
523
|
+
);
|
|
524
|
+
// Don't return — let it propagate for relay
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Forward peer approval resolution notifications to local IM channels
|
|
528
|
+
if (frame.type === "peer_approval_notify" && frame.from !== this.config.nodeId) {
|
|
529
|
+
const notify = frame as import("./types.ts").PeerApprovalNotify;
|
|
530
|
+
if (notify.payload.resolution) {
|
|
531
|
+
this.approvalManager.forwardResolutionToIM(
|
|
532
|
+
notify.payload.approvalId,
|
|
533
|
+
notify.payload.nodeId,
|
|
534
|
+
notify.payload.deviceInfo,
|
|
535
|
+
notify.payload.resolution.decision,
|
|
536
|
+
notify.payload.resolution.resolvedBy,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
// Don't return — let it propagate for relay
|
|
540
|
+
}
|
|
541
|
+
|
|
321
542
|
if (frame.type === "peer_sync") {
|
|
322
543
|
this.handlePeerSync(frame as PeerSync, from);
|
|
323
544
|
return;
|
|
@@ -345,6 +566,14 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
345
566
|
return;
|
|
346
567
|
}
|
|
347
568
|
|
|
569
|
+
// Forward to same-nodeId satellite connection (e.g. Mac desktop app) so that
|
|
570
|
+
// responses to requests the satellite originated are delivered back to it.
|
|
571
|
+
// Skip if the frame came from the satellite itself (avoid echo).
|
|
572
|
+
const sameNodeConn = this.router.getRoute(this.config.nodeId)?.connection;
|
|
573
|
+
if (sameNodeConn?.isOpen && sameNodeConn !== from) {
|
|
574
|
+
sameNodeConn.send(frame);
|
|
575
|
+
}
|
|
576
|
+
|
|
348
577
|
this.emit("frame", frame, from);
|
|
349
578
|
}
|
|
350
579
|
|
|
@@ -385,11 +614,13 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
385
614
|
const hadDirectPeers = prev?.directPeers.length ?? 0;
|
|
386
615
|
const hadToolProxy = JSON.stringify(prev?.toolProxy);
|
|
387
616
|
const hadDeviceInfo = prev?.deviceInfo?.hostname;
|
|
617
|
+
const hadAcpAgents = prev?.acpAgents?.length ?? 0;
|
|
388
618
|
this.router.updatePeerCapabilities(peer.nodeId, peer);
|
|
389
619
|
if (peer.agents.length !== hadAgents || peer.models.length !== (prev?.models.length ?? 0)
|
|
390
620
|
|| (peer.directPeers?.length ?? 0) !== hadDirectPeers
|
|
391
621
|
|| JSON.stringify(peer.toolProxy) !== hadToolProxy
|
|
392
|
-
|| peer.deviceInfo?.hostname !== hadDeviceInfo
|
|
622
|
+
|| peer.deviceInfo?.hostname !== hadDeviceInfo
|
|
623
|
+
|| (peer.acpAgents?.length ?? 0) !== hadAcpAgents) {
|
|
393
624
|
changed = true;
|
|
394
625
|
}
|
|
395
626
|
} else {
|
|
@@ -410,6 +641,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
410
641
|
}
|
|
411
642
|
}
|
|
412
643
|
}, 100);
|
|
644
|
+
this.emit("peerCapabilitiesChanged");
|
|
413
645
|
}
|
|
414
646
|
}
|
|
415
647
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Per-IP sliding window rate limiter for inbound WebSocket connections. */
|
|
2
|
+
|
|
3
|
+
export interface RateLimiterConfig {
|
|
4
|
+
/** Max connection attempts per IP within the window (default: 10). */
|
|
5
|
+
maxAttempts: number;
|
|
6
|
+
/** Sliding window duration in ms (default: 60000 = 1 minute). */
|
|
7
|
+
windowMs: number;
|
|
8
|
+
/** How often to run GC on expired entries in ms (default: 30000). */
|
|
9
|
+
gcIntervalMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CONFIG: RateLimiterConfig = {
|
|
13
|
+
maxAttempts: 10,
|
|
14
|
+
windowMs: 60_000,
|
|
15
|
+
gcIntervalMs: 30_000,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export class RateLimiter {
|
|
19
|
+
private config: RateLimiterConfig;
|
|
20
|
+
/** Map from IP → sorted array of timestamps (ms). */
|
|
21
|
+
private attempts = new Map<string, number[]>();
|
|
22
|
+
private gcTimer: ReturnType<typeof setInterval> | null = null;
|
|
23
|
+
|
|
24
|
+
constructor(config?: Partial<RateLimiterConfig>) {
|
|
25
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
26
|
+
this.gcTimer = setInterval(() => this.gc(), this.config.gcIntervalMs);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Returns true if the connection should be allowed, false if rate-limited. */
|
|
30
|
+
check(ip: string): boolean {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const cutoff = now - this.config.windowMs;
|
|
33
|
+
|
|
34
|
+
let timestamps = this.attempts.get(ip);
|
|
35
|
+
if (timestamps) {
|
|
36
|
+
// Remove expired entries
|
|
37
|
+
timestamps = timestamps.filter((t) => t > cutoff);
|
|
38
|
+
} else {
|
|
39
|
+
timestamps = [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (timestamps.length >= this.config.maxAttempts) {
|
|
43
|
+
this.attempts.set(ip, timestamps);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
timestamps.push(now);
|
|
48
|
+
this.attempts.set(ip, timestamps);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Record a failed auth attempt — counts as extra weight. */
|
|
53
|
+
recordFailure(ip: string) {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const timestamps = this.attempts.get(ip) ?? [];
|
|
56
|
+
// A failure counts as 3 attempts to accelerate blocking of brute-force
|
|
57
|
+
timestamps.push(now, now, now);
|
|
58
|
+
this.attempts.set(ip, timestamps);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Get remaining attempts for an IP. */
|
|
62
|
+
remaining(ip: string): number {
|
|
63
|
+
const cutoff = Date.now() - this.config.windowMs;
|
|
64
|
+
const timestamps = this.attempts.get(ip) ?? [];
|
|
65
|
+
const active = timestamps.filter((t) => t > cutoff).length;
|
|
66
|
+
return Math.max(0, this.config.maxAttempts - active);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private gc() {
|
|
70
|
+
const cutoff = Date.now() - this.config.windowMs;
|
|
71
|
+
for (const [ip, timestamps] of this.attempts) {
|
|
72
|
+
const active = timestamps.filter((t) => t > cutoff);
|
|
73
|
+
if (active.length === 0) {
|
|
74
|
+
this.attempts.delete(ip);
|
|
75
|
+
} else {
|
|
76
|
+
this.attempts.set(ip, active);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
destroy() {
|
|
82
|
+
if (this.gcTimer) {
|
|
83
|
+
clearInterval(this.gcTimer);
|
|
84
|
+
this.gcTimer = null;
|
|
85
|
+
}
|
|
86
|
+
this.attempts.clear();
|
|
87
|
+
}
|
|
88
|
+
}
|