clawmatrix 0.1.23 → 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 +288 -85
- package/src/crypto.ts +179 -0
- package/src/debug.ts +15 -2
- package/src/e2e/helpers.ts +318 -0
- package/src/handoff.ts +131 -86
- package/src/identity.ts +95 -0
- package/src/index.ts +467 -52
- package/src/knowledge-sync.ts +776 -207
- package/src/model-proxy.ts +144 -39
- package/src/peer-approval.ts +628 -0
- package/src/peer-manager.ts +261 -32
- 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 +475 -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>");
|
|
127
184
|
});
|
|
128
185
|
|
|
129
|
-
this.wss = new WebSocketServer({
|
|
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
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
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
|
|
|
@@ -241,8 +334,76 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
241
334
|
}
|
|
242
335
|
|
|
243
336
|
// ── Peer lifecycle ─────────────────────────────────────────────
|
|
244
|
-
private onPeerAuthenticated(conn: Connection, caps: NodeCapabilities) {
|
|
337
|
+
private onPeerAuthenticated(conn: Connection, caps: NodeCapabilities, ip?: string) {
|
|
245
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) 能将响应帧路由回客户端连接。
|
|
246
407
|
this.router.addDirectPeer(nodeId, conn, caps);
|
|
247
408
|
|
|
248
409
|
conn.on("message", (frame) => this.onFrame(frame, conn));
|
|
@@ -251,28 +412,32 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
251
412
|
|
|
252
413
|
this.sendPeerSync(conn);
|
|
253
414
|
|
|
254
|
-
//
|
|
255
|
-
this.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
}
|
|
273
437
|
}
|
|
274
438
|
}
|
|
275
439
|
|
|
440
|
+
audit("peer_join", { nodeId, detail: `agents=${caps.agents.length} models=${caps.models.length}` });
|
|
276
441
|
this.emit("peerConnected", nodeId);
|
|
277
442
|
}
|
|
278
443
|
|
|
@@ -280,6 +445,13 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
280
445
|
const nodeId = conn.remoteNodeId;
|
|
281
446
|
if (!nodeId) return;
|
|
282
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 });
|
|
283
455
|
this.router.removePeer(nodeId);
|
|
284
456
|
|
|
285
457
|
// Remove satellite contexts that were only reachable via this peer
|
|
@@ -302,7 +474,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
302
474
|
// ── Message handling ───────────────────────────────────────────
|
|
303
475
|
private onFrame(frame: AnyClusterFrame, from: Connection) {
|
|
304
476
|
// Validate from field: must be the direct peer or a known node (relayed)
|
|
305
|
-
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
|
+
}
|
|
306
481
|
|
|
307
482
|
// Skip dedup for streaming and response frame types.
|
|
308
483
|
// Stream frames share one id across many chunks.
|
|
@@ -318,9 +493,52 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
318
493
|
|| frame.type === "handoff_res" || frame.type === "handoff_status_res"
|
|
319
494
|
|| frame.type === "handoff_input_required"
|
|
320
495
|
|| frame.type === "handoff_input" || frame.type === "handoff_cancel"
|
|
321
|
-
|| 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";
|
|
322
507
|
if (frame.id && !skipDedup && this.router.isDuplicate(frame.id)) return;
|
|
323
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
|
+
|
|
324
542
|
if (frame.type === "peer_sync") {
|
|
325
543
|
this.handlePeerSync(frame as PeerSync, from);
|
|
326
544
|
return;
|
|
@@ -348,6 +566,14 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
348
566
|
return;
|
|
349
567
|
}
|
|
350
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
|
+
|
|
351
577
|
this.emit("frame", frame, from);
|
|
352
578
|
}
|
|
353
579
|
|
|
@@ -388,11 +614,13 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
388
614
|
const hadDirectPeers = prev?.directPeers.length ?? 0;
|
|
389
615
|
const hadToolProxy = JSON.stringify(prev?.toolProxy);
|
|
390
616
|
const hadDeviceInfo = prev?.deviceInfo?.hostname;
|
|
617
|
+
const hadAcpAgents = prev?.acpAgents?.length ?? 0;
|
|
391
618
|
this.router.updatePeerCapabilities(peer.nodeId, peer);
|
|
392
619
|
if (peer.agents.length !== hadAgents || peer.models.length !== (prev?.models.length ?? 0)
|
|
393
620
|
|| (peer.directPeers?.length ?? 0) !== hadDirectPeers
|
|
394
621
|
|| JSON.stringify(peer.toolProxy) !== hadToolProxy
|
|
395
|
-
|| peer.deviceInfo?.hostname !== hadDeviceInfo
|
|
622
|
+
|| peer.deviceInfo?.hostname !== hadDeviceInfo
|
|
623
|
+
|| (peer.acpAgents?.length ?? 0) !== hadAcpAgents) {
|
|
396
624
|
changed = true;
|
|
397
625
|
}
|
|
398
626
|
} else {
|
|
@@ -413,6 +641,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
413
641
|
}
|
|
414
642
|
}
|
|
415
643
|
}, 100);
|
|
644
|
+
this.emit("peerCapabilitiesChanged");
|
|
416
645
|
}
|
|
417
646
|
}
|
|
418
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
|
+
}
|