@mooncompany/uplink-chat 0.5.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.
Potentially problematic release.
This version of @mooncompany/uplink-chat might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- package/utils/with-retry.js +105 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Connections Module
|
|
3
|
+
* Client tracking, rate limiting, heartbeats, IP-based limits, cleanup
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { WebSocket } from 'ws';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import { log } from '../utils.js';
|
|
9
|
+
import { WEBSOCKET } from '../config.js';
|
|
10
|
+
|
|
11
|
+
// ============================================
|
|
12
|
+
// Shared State
|
|
13
|
+
// ============================================
|
|
14
|
+
|
|
15
|
+
/** Track connected clients: clientId -> { ws, lastPing, lastPong, missedPongs, sessionUser, connectedAt, clientIp } */
|
|
16
|
+
export const wsClients = new Map();
|
|
17
|
+
|
|
18
|
+
/** Rate limiting per client: clientId -> { count, resetAt } */
|
|
19
|
+
const wsRateLimits = new Map();
|
|
20
|
+
|
|
21
|
+
/** IP-based connection tracking: ip -> Set<clientId> */
|
|
22
|
+
const wsConnectionsByIp = new Map();
|
|
23
|
+
|
|
24
|
+
/** Active streaming requests: requestId -> { abortController, clientId, startedAt } */
|
|
25
|
+
export const activeStreamingRequests = new Map();
|
|
26
|
+
|
|
27
|
+
// ============================================
|
|
28
|
+
// Config Constants
|
|
29
|
+
// ============================================
|
|
30
|
+
|
|
31
|
+
const WS_RATE_LIMIT = {
|
|
32
|
+
windowMs: WEBSOCKET.messageRateLimitWindow,
|
|
33
|
+
maxMessages: WEBSOCKET.maxMessagesPerWindow,
|
|
34
|
+
};
|
|
35
|
+
const MAX_WS_RATE_LIMIT_ENTRIES = WEBSOCKET.maxRateLimitEntries;
|
|
36
|
+
const MAX_ACTIVE_STREAMING_REQUESTS = WEBSOCKET.maxStreamingRequests;
|
|
37
|
+
export const WS_CONNECTION_LIMITS = WEBSOCKET.connectionLimits;
|
|
38
|
+
export const MAX_MESSAGE_SIZE = WEBSOCKET.maxMessageSize;
|
|
39
|
+
const HEARTBEAT_INTERVAL_MS = WEBSOCKET.heartbeatIntervalMs;
|
|
40
|
+
const MAX_MISSED_PONGS = WEBSOCKET.maxMissedPongs;
|
|
41
|
+
|
|
42
|
+
// ============================================
|
|
43
|
+
// Client ID Generation
|
|
44
|
+
// ============================================
|
|
45
|
+
|
|
46
|
+
export function generateClientId() {
|
|
47
|
+
return `ws-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================
|
|
51
|
+
// Safe Send Helper
|
|
52
|
+
// ============================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Safely send a message to a WebSocket, checking readyState first
|
|
56
|
+
* Returns true if message was sent, false if connection was not open
|
|
57
|
+
*/
|
|
58
|
+
export function safeSend(ws, data) {
|
|
59
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
60
|
+
try {
|
|
61
|
+
ws.send(typeof data === 'string' ? data : JSON.stringify(data));
|
|
62
|
+
return true;
|
|
63
|
+
} catch (sendError) {
|
|
64
|
+
log('warn', `[WS] Failed to send message:`, sendError.message);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================
|
|
72
|
+
// Rate Limiting
|
|
73
|
+
// ============================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Trim oldest rate limit entries when map exceeds size limit
|
|
77
|
+
* Removes entries that are expired first, then oldest by resetAt
|
|
78
|
+
*/
|
|
79
|
+
function trimRateLimitEntries() {
|
|
80
|
+
if (wsRateLimits.size <= MAX_WS_RATE_LIMIT_ENTRIES) return;
|
|
81
|
+
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const entries = Array.from(wsRateLimits.entries());
|
|
84
|
+
|
|
85
|
+
// Sort by resetAt (oldest first)
|
|
86
|
+
entries.sort((a, b) => a[1].resetAt - b[1].resetAt);
|
|
87
|
+
|
|
88
|
+
const toRemove = wsRateLimits.size - MAX_WS_RATE_LIMIT_ENTRIES;
|
|
89
|
+
let removed = 0;
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < entries.length && removed < toRemove; i++) {
|
|
92
|
+
const [clientId] = entries[i];
|
|
93
|
+
// Only remove if client is disconnected OR entry is expired
|
|
94
|
+
if (!wsClients.has(clientId) || entries[i][1].resetAt < now) {
|
|
95
|
+
wsRateLimits.delete(clientId);
|
|
96
|
+
removed++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// If still over limit, force remove oldest
|
|
101
|
+
if (wsRateLimits.size > MAX_WS_RATE_LIMIT_ENTRIES) {
|
|
102
|
+
const remaining = Array.from(wsRateLimits.entries())
|
|
103
|
+
.sort((a, b) => a[1].resetAt - b[1].resetAt);
|
|
104
|
+
const forceRemove = wsRateLimits.size - MAX_WS_RATE_LIMIT_ENTRIES;
|
|
105
|
+
for (let i = 0; i < forceRemove; i++) {
|
|
106
|
+
wsRateLimits.delete(remaining[i][0]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function checkWsRateLimit(clientId) {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
let limit = wsRateLimits.get(clientId);
|
|
114
|
+
|
|
115
|
+
if (!limit || now > limit.resetAt) {
|
|
116
|
+
limit = { count: 0, resetAt: now + WS_RATE_LIMIT.windowMs };
|
|
117
|
+
wsRateLimits.set(clientId, limit);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
limit.count++;
|
|
121
|
+
|
|
122
|
+
// Ensure rate limit map doesn't grow unbounded
|
|
123
|
+
trimRateLimitEntries();
|
|
124
|
+
|
|
125
|
+
return limit.count <= WS_RATE_LIMIT.maxMessages;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Periodic cleanup of stale rate limit entries
|
|
130
|
+
* Removes entries that have expired and aren't associated with active clients
|
|
131
|
+
*/
|
|
132
|
+
export function cleanupStaleRateLimits() {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
let cleaned = 0;
|
|
135
|
+
|
|
136
|
+
for (const [clientId, limit] of wsRateLimits) {
|
|
137
|
+
// Remove if: entry expired AND client no longer connected
|
|
138
|
+
if (now > limit.resetAt && !wsClients.has(clientId)) {
|
|
139
|
+
wsRateLimits.delete(clientId);
|
|
140
|
+
cleaned++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (cleaned > 0) {
|
|
145
|
+
log('debug', `[WS] Cleaned ${cleaned} stale rate limit entries`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ============================================
|
|
150
|
+
// Streaming Request Management
|
|
151
|
+
// ============================================
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Trim oldest streaming requests when map exceeds size limit
|
|
155
|
+
* Aborts and removes oldest requests
|
|
156
|
+
*/
|
|
157
|
+
export function trimStreamingRequests() {
|
|
158
|
+
if (activeStreamingRequests.size <= MAX_ACTIVE_STREAMING_REQUESTS) return;
|
|
159
|
+
|
|
160
|
+
const entries = Array.from(activeStreamingRequests.entries());
|
|
161
|
+
// Sort by startedAt (oldest first)
|
|
162
|
+
entries.sort((a, b) => (a[1].startedAt || 0) - (b[1].startedAt || 0));
|
|
163
|
+
|
|
164
|
+
const toRemove = activeStreamingRequests.size - MAX_ACTIVE_STREAMING_REQUESTS;
|
|
165
|
+
|
|
166
|
+
for (let i = 0; i < toRemove; i++) {
|
|
167
|
+
const [requestId, request] = entries[i];
|
|
168
|
+
try {
|
|
169
|
+
request.abortController.abort();
|
|
170
|
+
} catch (abortError) {
|
|
171
|
+
// Ignore abort errors
|
|
172
|
+
}
|
|
173
|
+
activeStreamingRequests.delete(requestId);
|
|
174
|
+
log('warn', `[WS] Evicted oldest streaming request ${requestId} due to size limit`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ============================================
|
|
179
|
+
// Connection Limits & IP Tracking
|
|
180
|
+
// ============================================
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check connection limits and return whether a new connection should be allowed
|
|
184
|
+
*/
|
|
185
|
+
export function checkConnectionLimits(clientIp) {
|
|
186
|
+
// Check total connection limit
|
|
187
|
+
if (wsClients.size >= WS_CONNECTION_LIMITS.maxTotal) {
|
|
188
|
+
return { allowed: false, reason: 'Server at capacity' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Check per-IP limit
|
|
192
|
+
const existingConnections = wsConnectionsByIp.get(clientIp);
|
|
193
|
+
if (existingConnections && existingConnections.size >= WS_CONNECTION_LIMITS.maxPerIp) {
|
|
194
|
+
return { allowed: false, reason: 'Connection limit exceeded for this IP' };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { allowed: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Track a new connection for an IP address
|
|
202
|
+
*/
|
|
203
|
+
export function trackConnection(clientId, clientIp) {
|
|
204
|
+
if (!wsConnectionsByIp.has(clientIp)) {
|
|
205
|
+
wsConnectionsByIp.set(clientIp, new Set());
|
|
206
|
+
}
|
|
207
|
+
wsConnectionsByIp.get(clientIp).add(clientId);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Remove a connection from IP tracking
|
|
212
|
+
*/
|
|
213
|
+
function untrackConnection(clientId, clientIp) {
|
|
214
|
+
const ipConnections = wsConnectionsByIp.get(clientIp);
|
|
215
|
+
if (ipConnections) {
|
|
216
|
+
ipConnections.delete(clientId);
|
|
217
|
+
if (ipConnections.size === 0) {
|
|
218
|
+
wsConnectionsByIp.delete(clientIp);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================
|
|
224
|
+
// Client Cleanup
|
|
225
|
+
// ============================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Clean up all resources associated with a client
|
|
229
|
+
* Called on disconnect (close/error) to prevent memory leaks
|
|
230
|
+
* Safe to call multiple times — early-returns if already cleaned up (H-24)
|
|
231
|
+
*
|
|
232
|
+
* @param {string} clientId
|
|
233
|
+
* @param {string} clientIp
|
|
234
|
+
* @param {Function} cleanupSyncForClient - callback to clean sync delta throttles for this client
|
|
235
|
+
*/
|
|
236
|
+
export function cleanupClient(clientId, clientIp, cleanupSyncForClient) {
|
|
237
|
+
// 1. Remove from clients map (guard against double-cleanup race condition)
|
|
238
|
+
const client = wsClients.get(clientId);
|
|
239
|
+
if (!client) return; // Already cleaned up
|
|
240
|
+
wsClients.delete(clientId);
|
|
241
|
+
|
|
242
|
+
// 2. Remove rate limit entry
|
|
243
|
+
wsRateLimits.delete(clientId);
|
|
244
|
+
|
|
245
|
+
// 3. Remove from IP tracking
|
|
246
|
+
if (clientIp) {
|
|
247
|
+
untrackConnection(clientId, clientIp);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 4. Abort any active streaming requests for this client
|
|
251
|
+
let abortedCount = 0;
|
|
252
|
+
for (const [requestId, request] of activeStreamingRequests) {
|
|
253
|
+
if (request.clientId === clientId) {
|
|
254
|
+
try {
|
|
255
|
+
request.abortController.abort();
|
|
256
|
+
} catch (abortError) {
|
|
257
|
+
// Ignore abort errors
|
|
258
|
+
}
|
|
259
|
+
activeStreamingRequests.delete(requestId);
|
|
260
|
+
abortedCount++;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 5. Clean up any sync delta throttles for this client
|
|
265
|
+
if (cleanupSyncForClient) {
|
|
266
|
+
cleanupSyncForClient(clientId);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 6. Trim streaming requests map after cleanup
|
|
270
|
+
trimStreamingRequests();
|
|
271
|
+
|
|
272
|
+
log('debug', `[WS] Cleaned up client ${clientId}: rate limit removed, ${abortedCount} active requests aborted`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ============================================
|
|
276
|
+
// Heartbeat
|
|
277
|
+
// ============================================
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Start server-initiated heartbeat interval
|
|
281
|
+
* Pings all connected clients and terminates those that miss too many pongs
|
|
282
|
+
* @param {Function} cleanupSyncForClient - callback to clean sync delta throttles
|
|
283
|
+
* @returns {NodeJS.Timeout} interval handle
|
|
284
|
+
*/
|
|
285
|
+
export function startHeartbeat(cleanupSyncForClient) {
|
|
286
|
+
return setInterval(() => {
|
|
287
|
+
for (const [clientId, client] of wsClients) {
|
|
288
|
+
if (client.ws.readyState === WebSocket.OPEN) {
|
|
289
|
+
// Check if client missed too many pongs
|
|
290
|
+
if (client.missedPongs >= MAX_MISSED_PONGS) {
|
|
291
|
+
log('warn', `[WS] Client ${clientId} missed ${client.missedPongs} pongs, closing connection`);
|
|
292
|
+
client.ws.terminate();
|
|
293
|
+
cleanupClient(clientId, client.clientIp, cleanupSyncForClient);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Increment missed pongs counter (will be reset when pong is received)
|
|
298
|
+
client.missedPongs++;
|
|
299
|
+
|
|
300
|
+
// Send ping
|
|
301
|
+
try {
|
|
302
|
+
client.ws.ping();
|
|
303
|
+
} catch (err) {
|
|
304
|
+
log('warn', `[WS] Failed to ping client ${clientId}:`, err.message);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Start periodic cleanup of dead connections (every 1 minute)
|
|
313
|
+
* @param {Function} cleanupSyncForClient - callback to clean sync delta throttles
|
|
314
|
+
* @returns {NodeJS.Timeout} interval handle
|
|
315
|
+
*/
|
|
316
|
+
export function startDeadConnectionCleanup(cleanupSyncForClient) {
|
|
317
|
+
return setInterval(() => {
|
|
318
|
+
const now = Date.now();
|
|
319
|
+
const timeout = 5 * 60 * 1000;
|
|
320
|
+
|
|
321
|
+
for (const [clientId, client] of wsClients) {
|
|
322
|
+
if (now - client.lastPing > timeout) {
|
|
323
|
+
log('info', `[WS] Cleaning up stale connection: ${clientId}`);
|
|
324
|
+
client.ws.terminate();
|
|
325
|
+
cleanupClient(clientId, client.clientIp, cleanupSyncForClient);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}, 60 * 1000);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Start periodic cleanup of stale rate limit entries (every 5 minutes)
|
|
333
|
+
* @returns {NodeJS.Timeout} interval handle
|
|
334
|
+
*/
|
|
335
|
+
export function startRateLimitCleanup() {
|
|
336
|
+
return setInterval(() => {
|
|
337
|
+
cleanupStaleRateLimits();
|
|
338
|
+
}, 5 * 60 * 1000);
|
|
339
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Module - Real-time bidirectional communication
|
|
3
|
+
*
|
|
4
|
+
* Thin orchestrator: creates the WebSocket.Server, delegates to submodules
|
|
5
|
+
* for connection management, message routing, broadcasting, and sync.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { WebSocketServer } from 'ws';
|
|
9
|
+
import { log } from '../utils.js';
|
|
10
|
+
import { ALLOWED_ORIGINS, SESSION_USER } from '../config.js';
|
|
11
|
+
import { verifyWebSocketToken, isAuthEnabled } from '../middleware/auth.js';
|
|
12
|
+
|
|
13
|
+
// Connections
|
|
14
|
+
import {
|
|
15
|
+
wsClients,
|
|
16
|
+
generateClientId,
|
|
17
|
+
safeSend,
|
|
18
|
+
checkWsRateLimit,
|
|
19
|
+
checkConnectionLimits,
|
|
20
|
+
trackConnection,
|
|
21
|
+
cleanupClient,
|
|
22
|
+
startHeartbeat,
|
|
23
|
+
startDeadConnectionCleanup,
|
|
24
|
+
startRateLimitCleanup,
|
|
25
|
+
MAX_MESSAGE_SIZE,
|
|
26
|
+
} from './connections.js';
|
|
27
|
+
|
|
28
|
+
// Routing
|
|
29
|
+
import { handleWebSocketMessage } from './routing.js';
|
|
30
|
+
|
|
31
|
+
// Sync
|
|
32
|
+
import { cleanupSyncForClient } from './sync.js';
|
|
33
|
+
|
|
34
|
+
// Re-export public API (same interface as the original websocket.js)
|
|
35
|
+
export { wsClients } from './connections.js';
|
|
36
|
+
export {
|
|
37
|
+
broadcast,
|
|
38
|
+
broadcastToAll,
|
|
39
|
+
sendToClient,
|
|
40
|
+
generateMessageId,
|
|
41
|
+
broadcastSyncMessage,
|
|
42
|
+
broadcastSyncThinking,
|
|
43
|
+
broadcastSyncTool,
|
|
44
|
+
broadcastSyncComplete,
|
|
45
|
+
broadcastOpenClawPush,
|
|
46
|
+
trackBroadcast,
|
|
47
|
+
} from './broadcast.js';
|
|
48
|
+
export {
|
|
49
|
+
broadcastSyncDelta,
|
|
50
|
+
cleanupSyncDeltaThrottle,
|
|
51
|
+
} from './sync.js';
|
|
52
|
+
|
|
53
|
+
// ============================================
|
|
54
|
+
// Origin Verification
|
|
55
|
+
// ============================================
|
|
56
|
+
|
|
57
|
+
function verifyWebSocketOrigin(origin) {
|
|
58
|
+
// Reject null/undefined origins (L-02: WebSocket origin validation)
|
|
59
|
+
if (!origin) return false;
|
|
60
|
+
try {
|
|
61
|
+
const originUrl = new URL(origin);
|
|
62
|
+
// Allow localhost, configured origins, and Tailscale domains
|
|
63
|
+
return ALLOWED_ORIGINS.some(allowed => {
|
|
64
|
+
const allowedUrl = new URL(allowed);
|
|
65
|
+
return originUrl.hostname === allowedUrl.hostname;
|
|
66
|
+
}) || originUrl.hostname === 'localhost'
|
|
67
|
+
|| originUrl.hostname === '127.0.0.1'
|
|
68
|
+
|| originUrl.hostname.endsWith('.ts.net');
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================
|
|
75
|
+
// Setup WebSocket Server
|
|
76
|
+
// ============================================
|
|
77
|
+
|
|
78
|
+
export function setupWebSocket(server, requestHelpers, saveMessageToSync) {
|
|
79
|
+
const wss = new WebSocketServer({
|
|
80
|
+
noServer: true,
|
|
81
|
+
maxPayload: MAX_MESSAGE_SIZE // 1MB limit
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Handle upgrade requests for /ws path (noServer mode to avoid
|
|
85
|
+
// aborting other WebSocket paths like /api/realtime and /gateway)
|
|
86
|
+
server.on('upgrade', (request, socket, head) => {
|
|
87
|
+
const { pathname } = new URL(request.url, `http://${request.headers.host}`);
|
|
88
|
+
|
|
89
|
+
if (pathname !== '/ws') return; // Let other handlers take it
|
|
90
|
+
|
|
91
|
+
const origin = request.headers.origin;
|
|
92
|
+
const isValidOrigin = verifyWebSocketOrigin(origin);
|
|
93
|
+
if (!isValidOrigin) {
|
|
94
|
+
log('warn', `[WS] Rejected connection from invalid origin: ${origin}`);
|
|
95
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
96
|
+
socket.destroy();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const clientIp = request.socket.remoteAddress;
|
|
101
|
+
const limitCheck = checkConnectionLimits(clientIp);
|
|
102
|
+
if (!limitCheck.allowed) {
|
|
103
|
+
log('warn', `[WS] Rejected connection from ${clientIp}: ${limitCheck.reason}`);
|
|
104
|
+
socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
|
|
105
|
+
socket.destroy();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isAuthEnabled()) {
|
|
110
|
+
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
111
|
+
const queryToken = url.searchParams.get('token');
|
|
112
|
+
const authHeader = request.headers.authorization;
|
|
113
|
+
const token = queryToken || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : authHeader);
|
|
114
|
+
|
|
115
|
+
if (!verifyWebSocketToken(token)) {
|
|
116
|
+
log('warn', `[WS] Rejected connection from ${clientIp}: invalid or missing auth token`);
|
|
117
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
118
|
+
socket.destroy();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
124
|
+
wss.emit('connection', ws, request);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
wss.on('connection', (ws, req) => {
|
|
129
|
+
const clientId = generateClientId();
|
|
130
|
+
const clientIp = req.socket.remoteAddress;
|
|
131
|
+
|
|
132
|
+
// Track connection for IP-based limits
|
|
133
|
+
trackConnection(clientId, clientIp);
|
|
134
|
+
|
|
135
|
+
log('info', `[WS] Client connected: ${clientId} from ${clientIp}`);
|
|
136
|
+
|
|
137
|
+
wsClients.set(clientId, {
|
|
138
|
+
ws,
|
|
139
|
+
lastPing: Date.now(),
|
|
140
|
+
lastPong: Date.now(), // Track pong responses for server-initiated heartbeat
|
|
141
|
+
missedPongs: 0, // Count of missed pong responses
|
|
142
|
+
sessionUser: SESSION_USER,
|
|
143
|
+
connectedAt: Date.now(),
|
|
144
|
+
clientIp
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Handle pong responses from server-initiated pings
|
|
148
|
+
ws.on('pong', () => {
|
|
149
|
+
const client = wsClients.get(clientId);
|
|
150
|
+
if (client) {
|
|
151
|
+
client.lastPong = Date.now();
|
|
152
|
+
client.missedPongs = 0;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
safeSend(ws, {
|
|
157
|
+
type: 'connected',
|
|
158
|
+
clientId,
|
|
159
|
+
timestamp: new Date().toISOString()
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
ws.on('message', async (data) => {
|
|
163
|
+
// Manual message size validation (defense in depth with maxPayload)
|
|
164
|
+
if (data.length > MAX_MESSAGE_SIZE) {
|
|
165
|
+
log('warn', `[WS] Message too large from ${clientId}: ${data.length} bytes`);
|
|
166
|
+
safeSend(ws, {
|
|
167
|
+
type: 'error',
|
|
168
|
+
error: `Message too large. Maximum size is ${MAX_MESSAGE_SIZE / 1024 / 1024}MB.`
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!checkWsRateLimit(clientId)) {
|
|
174
|
+
log('warn', `[WS] Rate limit exceeded for ${clientId}`);
|
|
175
|
+
safeSend(ws, {
|
|
176
|
+
type: 'error',
|
|
177
|
+
error: 'Rate limit exceeded. Please slow down.'
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const message = JSON.parse(data.toString());
|
|
184
|
+
await handleWebSocketMessage(clientId, message, requestHelpers, saveMessageToSync);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
log('error', `[WS] Error handling message from ${clientId}:`, err.message);
|
|
187
|
+
safeSend(ws, {
|
|
188
|
+
type: 'error',
|
|
189
|
+
error: 'Invalid message format'
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
ws.on('close', (code) => {
|
|
195
|
+
log('info', `[WS] Client disconnected: ${clientId} (code: ${code})`);
|
|
196
|
+
cleanupClient(clientId, clientIp, cleanupSyncForClient);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
ws.on('error', (err) => {
|
|
200
|
+
log('error', `[WS] Error for ${clientId}:`, err.message);
|
|
201
|
+
cleanupClient(clientId, clientIp, cleanupSyncForClient);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Server-initiated heartbeat (ping/pong)
|
|
206
|
+
startHeartbeat(cleanupSyncForClient);
|
|
207
|
+
|
|
208
|
+
// Periodic cleanup of dead connections (every 1 minute)
|
|
209
|
+
startDeadConnectionCleanup(cleanupSyncForClient);
|
|
210
|
+
|
|
211
|
+
// Periodic cleanup of stale rate limit entries (every 5 minutes)
|
|
212
|
+
startRateLimitCleanup();
|
|
213
|
+
|
|
214
|
+
return wss;
|
|
215
|
+
}
|