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.
@@ -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>");
127
184
  });
128
185
 
129
- this.wss = new WebSocketServer({ server: this.httpServer });
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
- 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
 
@@ -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
- // Broadcast peer_join to other peers
255
- this.router.broadcast({
256
- type: "peer_join",
257
- from: this.config.nodeId,
258
- timestamp: Date.now(),
259
- payload: {
260
- nodeId,
261
- agents: caps.agents,
262
- models: caps.models,
263
- tags: caps.tags,
264
- deviceInfo: caps.deviceInfo,
265
- toolProxy: caps.toolProxy,
266
- },
267
- } as AnyClusterFrame);
268
-
269
- // Re-sync with all existing peers so they learn about the new node
270
- for (const existingConn of this.router.getDirectConnections()) {
271
- if (existingConn !== conn && existingConn.isOpen) {
272
- 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
+ }
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)) 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
+ }
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
+ }