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.
@@ -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
- res.writeHead(426, { "Content-Type": "text/plain" });
126
- res.end("WebSocket upgrade required");
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 = new WebSocketServer({ server: this.httpServer });
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
- this.wss.on("connection", (ws) => {
132
- this.handleInboundOpen(ws);
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
- this.onPeerAuthenticated(conn, caps);
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
- const ws = new WebSocket(peer.url);
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
- ws.addEventListener("error", () => {
217
- this.scheduleReconnect(peer);
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("close", () => {
221
- this.scheduleReconnect(peer);
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
- // Broadcast peer_join to other peers
252
- this.router.broadcast({
253
- type: "peer_join",
254
- from: this.config.nodeId,
255
- timestamp: Date.now(),
256
- payload: {
257
- nodeId,
258
- agents: caps.agents,
259
- models: caps.models,
260
- tags: caps.tags,
261
- deviceInfo: caps.deviceInfo,
262
- toolProxy: caps.toolProxy,
263
- },
264
- } as AnyClusterFrame);
265
-
266
- // Re-sync with all existing peers so they learn about the new node
267
- for (const existingConn of this.router.getDirectConnections()) {
268
- if (existingConn !== conn && existingConn.isOpen) {
269
- this.sendPeerSync(existingConn);
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)) return;
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
+ }