@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,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway WebSocket Proxy - Forwards client connections to OpenClaw Gateway
|
|
3
|
+
*
|
|
4
|
+
* This module creates a WebSocket endpoint at /ws/gateway that proxies
|
|
5
|
+
* connections to the local OpenClaw Gateway, enabling browser clients to
|
|
6
|
+
* communicate directly with the Gateway.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
10
|
+
import { log } from './utils.js';
|
|
11
|
+
import { ALLOWED_ORIGINS } from './config.js';
|
|
12
|
+
|
|
13
|
+
// Default Gateway WebSocket URL
|
|
14
|
+
const GATEWAY_PROXY_TARGET = process.env.GATEWAY_PROXY_TARGET || 'ws://localhost:18789/ws';
|
|
15
|
+
|
|
16
|
+
// Connection limits
|
|
17
|
+
const MAX_PROXY_CONNECTIONS = 10;
|
|
18
|
+
const MAX_CONNECTIONS_PER_IP = 5;
|
|
19
|
+
const ipConnectionCounts = new Map(); // ip -> count
|
|
20
|
+
|
|
21
|
+
// Track connected proxy clients for sync broadcasts
|
|
22
|
+
const proxyClients = new Map(); // clientId -> { ws, gatewayWs }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Verify if the origin is allowed for proxy connections
|
|
26
|
+
* Checks localhost, 127.0.0.1, *.ts.net, and ALLOWED_ORIGINS
|
|
27
|
+
* @param {string|undefined} origin - Origin header value
|
|
28
|
+
* @returns {boolean} - True if origin is allowed
|
|
29
|
+
*/
|
|
30
|
+
function verifyProxyOrigin(origin) {
|
|
31
|
+
// NOTE: Unlike /ws (which rejects null origins per L-02), the proxy ALLOWS null origins.
|
|
32
|
+
// This is intentional: non-browser WS clients (Node.js, curl, server-side tools) don't send
|
|
33
|
+
// Origin headers, and the Gateway itself validates the forwarded Bearer token.
|
|
34
|
+
// The /ws endpoint is browser-only, so rejecting null there prevents non-browser abuse.
|
|
35
|
+
// Here, the proxy serves both browser and non-browser clients.
|
|
36
|
+
if (!origin) return true;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(origin);
|
|
40
|
+
const hostname = url.hostname;
|
|
41
|
+
|
|
42
|
+
// Allow localhost and loopback
|
|
43
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Allow Tailscale domains (*.ts.net)
|
|
48
|
+
if (hostname.endsWith('.ts.net')) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check configured ALLOWED_ORIGINS
|
|
53
|
+
if (ALLOWED_ORIGINS && ALLOWED_ORIGINS.includes(origin)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// Invalid origin URL
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Sets up the Gateway WebSocket proxy on path /gateway
|
|
66
|
+
* Uses noServer mode to avoid conflicts with other WebSocket servers
|
|
67
|
+
* @param {http.Server} server - HTTP server instance
|
|
68
|
+
*/
|
|
69
|
+
export function setupGatewayProxy(server) {
|
|
70
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
71
|
+
|
|
72
|
+
// Handle upgrade requests for /gateway path
|
|
73
|
+
server.on('upgrade', (request, socket, head) => {
|
|
74
|
+
const { pathname } = new URL(request.url, `http://${request.headers.host}`);
|
|
75
|
+
log('debug', `[Gateway Proxy] Upgrade request for: ${pathname}`);
|
|
76
|
+
|
|
77
|
+
if (pathname === '/gateway') {
|
|
78
|
+
log('debug', '[Gateway Proxy] Handling /gateway upgrade');
|
|
79
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
80
|
+
log('debug', '[Gateway Proxy] Upgrade complete, emitting connection');
|
|
81
|
+
wss.emit('connection', ws, request);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Don't handle other paths - let other WebSocket servers handle them
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
wss.on('connection', (clientWs, req) => {
|
|
88
|
+
const clientIp = req.socket.remoteAddress;
|
|
89
|
+
const clientId = `proxy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
90
|
+
|
|
91
|
+
// Origin validation
|
|
92
|
+
const origin = req.headers.origin;
|
|
93
|
+
if (!verifyProxyOrigin(origin)) {
|
|
94
|
+
log('warn', `[Gateway Proxy] Rejected connection from ${clientIp} — invalid origin: ${origin}`);
|
|
95
|
+
clientWs.close(1008, 'Origin not allowed');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Global connection limit
|
|
100
|
+
if (proxyClients.size >= MAX_PROXY_CONNECTIONS) {
|
|
101
|
+
log('warn', `[Gateway Proxy] Rejected connection from ${clientIp} — max connections reached (${MAX_PROXY_CONNECTIONS})`);
|
|
102
|
+
clientWs.close(1013, 'Too many connections');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Per-IP connection limit
|
|
107
|
+
const currentIpCount = ipConnectionCounts.get(clientIp) || 0;
|
|
108
|
+
if (currentIpCount >= MAX_CONNECTIONS_PER_IP) {
|
|
109
|
+
log('warn', `[Gateway Proxy] Rejected connection from ${clientIp} — per-IP limit reached (${MAX_CONNECTIONS_PER_IP})`);
|
|
110
|
+
clientWs.close(1013, 'Too many connections from this IP');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
ipConnectionCounts.set(clientIp, currentIpCount + 1);
|
|
114
|
+
|
|
115
|
+
log('info', `[Gateway Proxy] Client connected from ${clientIp} (${clientId})`);
|
|
116
|
+
|
|
117
|
+
// Get auth token from query param (browser WS can't set headers) or Authorization header
|
|
118
|
+
// NOTE (M-37 / C-04): Token in query string is INTENTIONAL for browser WebSocket compatibility.
|
|
119
|
+
// Browser WebSocket API cannot set custom headers, so we accept ?token= query param.
|
|
120
|
+
// The proxy correctly forwards this as Authorization: Bearer header to the Gateway.
|
|
121
|
+
// Risk is minimal on local/Tailscale networks (no proxy/CDN logging query strings).
|
|
122
|
+
// This is the same pattern used by OpenClaw's own browser clients.
|
|
123
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
124
|
+
const queryToken = url.searchParams.get('token');
|
|
125
|
+
const authHeader = req.headers.authorization;
|
|
126
|
+
const token = queryToken || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : authHeader);
|
|
127
|
+
|
|
128
|
+
// Create connection options for the Gateway
|
|
129
|
+
const gatewayOptions = {};
|
|
130
|
+
if (token) {
|
|
131
|
+
// Forward token as proper Bearer header to Gateway (Gateway validates it)
|
|
132
|
+
gatewayOptions.headers = {
|
|
133
|
+
'Authorization': `Bearer ${token}`
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Create outbound connection to Gateway
|
|
138
|
+
let gatewayWs;
|
|
139
|
+
try {
|
|
140
|
+
gatewayWs = new WebSocket(GATEWAY_PROXY_TARGET, gatewayOptions);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log('error', '[Gateway Proxy] Failed to create Gateway connection:', err.message);
|
|
143
|
+
clientWs.close(1011, 'Failed to connect to Gateway');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let clientClosed = false;
|
|
148
|
+
let gatewayClosed = false;
|
|
149
|
+
|
|
150
|
+
// Forward messages from client to Gateway
|
|
151
|
+
clientWs.on('message', (data) => {
|
|
152
|
+
if (gatewayWs.readyState === WebSocket.OPEN) {
|
|
153
|
+
try {
|
|
154
|
+
gatewayWs.send(data);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
log('error', '[Gateway Proxy] Error forwarding to Gateway:', err.message);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Forward messages from Gateway to client
|
|
162
|
+
gatewayWs.on('message', (data) => {
|
|
163
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
164
|
+
try {
|
|
165
|
+
clientWs.send(data);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
log('error', '[Gateway Proxy] Error forwarding to client:', err.message);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Handle client close
|
|
173
|
+
clientWs.on('close', (code, reason) => {
|
|
174
|
+
clientClosed = true;
|
|
175
|
+
log('info', `[Gateway Proxy] Client disconnected (code: ${code})`);
|
|
176
|
+
proxyClients.delete(clientId);
|
|
177
|
+
// Decrement per-IP connection count
|
|
178
|
+
const count = ipConnectionCounts.get(clientIp) || 1;
|
|
179
|
+
if (count <= 1) {
|
|
180
|
+
ipConnectionCounts.delete(clientIp);
|
|
181
|
+
} else {
|
|
182
|
+
ipConnectionCounts.set(clientIp, count - 1);
|
|
183
|
+
}
|
|
184
|
+
if (!gatewayClosed && gatewayWs.readyState === WebSocket.OPEN) {
|
|
185
|
+
gatewayWs.close();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Handle Gateway close
|
|
190
|
+
gatewayWs.on('close', (code, reason) => {
|
|
191
|
+
gatewayClosed = true;
|
|
192
|
+
log('info', `[Gateway Proxy] Gateway disconnected (code: ${code})`);
|
|
193
|
+
if (!clientClosed && clientWs.readyState === WebSocket.OPEN) {
|
|
194
|
+
clientWs.close();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Handle client errors
|
|
199
|
+
clientWs.on('error', (err) => {
|
|
200
|
+
log('error', '[Gateway Proxy] Client error:', err.message);
|
|
201
|
+
if (!gatewayClosed && gatewayWs.readyState === WebSocket.OPEN) {
|
|
202
|
+
gatewayWs.close();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Handle Gateway errors
|
|
207
|
+
gatewayWs.on('error', (err) => {
|
|
208
|
+
log('error', '[Gateway Proxy] Gateway error:', err.message);
|
|
209
|
+
// Send error to client if still connected
|
|
210
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
211
|
+
try {
|
|
212
|
+
clientWs.send(JSON.stringify({
|
|
213
|
+
type: 'error',
|
|
214
|
+
error: 'Gateway connection error: ' + err.message
|
|
215
|
+
}));
|
|
216
|
+
} catch (e) {
|
|
217
|
+
// Ignore send errors
|
|
218
|
+
}
|
|
219
|
+
clientWs.close(1011, 'Gateway connection error');
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Handle Gateway connection open
|
|
224
|
+
gatewayWs.on('open', () => {
|
|
225
|
+
log('debug', '[Gateway Proxy] Connected to Gateway');
|
|
226
|
+
// Track client for sync broadcasts
|
|
227
|
+
proxyClients.set(clientId, { ws: clientWs, gatewayWs, lastActivity: Date.now() });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Keepalive ping/pong to prevent proxy timeouts (Tailscale, etc.)
|
|
231
|
+
const keepaliveInterval = setInterval(() => {
|
|
232
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
233
|
+
try {
|
|
234
|
+
clientWs.ping();
|
|
235
|
+
} catch (err) {
|
|
236
|
+
log('warn', '[Gateway Proxy] Ping failed:', err.message);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (gatewayWs.readyState === WebSocket.OPEN) {
|
|
240
|
+
try {
|
|
241
|
+
gatewayWs.ping();
|
|
242
|
+
} catch (err) {
|
|
243
|
+
log('warn', '[Gateway Proxy] Gateway ping failed:', err.message);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}, 15000); // 15 seconds
|
|
247
|
+
|
|
248
|
+
// Clean up interval on close
|
|
249
|
+
const cleanupKeepalive = () => {
|
|
250
|
+
clearInterval(keepaliveInterval);
|
|
251
|
+
};
|
|
252
|
+
clientWs.on('close', cleanupKeepalive);
|
|
253
|
+
gatewayWs.on('close', cleanupKeepalive);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Clean up on close (add to existing close handlers above)
|
|
257
|
+
wss.on('close', () => {
|
|
258
|
+
proxyClients.clear();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
log('info', `[Gateway Proxy] WebSocket proxy ready at /gateway -> ${GATEWAY_PROXY_TARGET}`);
|
|
262
|
+
return wss;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Broadcast a message to all Gateway proxy clients
|
|
267
|
+
* Used for cross-device sync to reach mobile clients
|
|
268
|
+
* @param {Object|string} message - Message to send
|
|
269
|
+
* @returns {{ sent: number, failed: number }}
|
|
270
|
+
*/
|
|
271
|
+
export function broadcastToProxyClients(message) {
|
|
272
|
+
const data = typeof message === 'string' ? message : JSON.stringify(message);
|
|
273
|
+
let sent = 0;
|
|
274
|
+
let failed = 0;
|
|
275
|
+
|
|
276
|
+
log('info', `[Gateway Proxy] Broadcasting to ${proxyClients.size} proxy clients`);
|
|
277
|
+
|
|
278
|
+
for (const [clientId, { ws }] of proxyClients) {
|
|
279
|
+
try {
|
|
280
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
281
|
+
ws.send(data);
|
|
282
|
+
sent++;
|
|
283
|
+
log('debug', `[Gateway Proxy] Sent to ${clientId}`);
|
|
284
|
+
} else {
|
|
285
|
+
log('warn', `[Gateway Proxy] Client ${clientId} not open (state: ${ws.readyState})`);
|
|
286
|
+
}
|
|
287
|
+
} catch (err) {
|
|
288
|
+
failed++;
|
|
289
|
+
log('warn', `[Gateway Proxy] Broadcast failed for ${clientId}: ${err.message}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
log('info', `[Gateway Proxy] Broadcast complete: ${sent} sent, ${failed} failed`);
|
|
294
|
+
|
|
295
|
+
return { sent, failed };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Get count of connected proxy clients
|
|
300
|
+
*/
|
|
301
|
+
export function getProxyClientCount() {
|
|
302
|
+
return proxyClients.size;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Get proxy client info for debugging
|
|
307
|
+
*/
|
|
308
|
+
export function getProxyClientInfo() {
|
|
309
|
+
const clients = [];
|
|
310
|
+
for (const [clientId, { ws }] of proxyClients) {
|
|
311
|
+
clients.push({
|
|
312
|
+
id: clientId,
|
|
313
|
+
readyState: ws.readyState,
|
|
314
|
+
readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][ws.readyState] || 'UNKNOWN'
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return { count: proxyClients.size, clients };
|
|
318
|
+
}
|
package/server/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uplink Server Modules
|
|
3
|
+
*
|
|
4
|
+
* This directory contains extracted modules from the main server.js
|
|
5
|
+
* to improve maintainability and separation of concerns.
|
|
6
|
+
*
|
|
7
|
+
* Module Structure:
|
|
8
|
+
* - utils.js - Common utilities (logging, fetch, cleanup, SSRF prevention)
|
|
9
|
+
* - tts.js - ElevenLabs TTS (configurable voice)
|
|
10
|
+
* - share.js - Public share links for conversations
|
|
11
|
+
* - sync.js - Encrypted cross-device sync
|
|
12
|
+
*
|
|
13
|
+
* Future extractions (still in server.js):
|
|
14
|
+
* - chat.js - Chat endpoints (voice and text)
|
|
15
|
+
* - webhooks.js - External webhook integrations
|
|
16
|
+
* - websocket.js - WebSocket handling
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export * from './utils.js';
|
|
20
|
+
export * from './tts.js';
|
|
21
|
+
export { setupShareRoutes } from './share.js';
|
|
22
|
+
export { setupSyncRoutes } from './sync.js';
|
package/server/logger.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logger for Uplink
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { createLogger } from './logger.js';
|
|
6
|
+
* const log = createLogger('my-module');
|
|
7
|
+
*
|
|
8
|
+
* log.debug('verbose debugging info', { data });
|
|
9
|
+
* log.info('normal operational message');
|
|
10
|
+
* log.warn('something fishy', error);
|
|
11
|
+
* log.error('something broke', error);
|
|
12
|
+
*
|
|
13
|
+
* Environment:
|
|
14
|
+
* LOG_LEVEL=debug|info|warn|error|silent (default: info)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const LOG_LEVELS = {
|
|
18
|
+
debug: 0,
|
|
19
|
+
info: 1,
|
|
20
|
+
warn: 2,
|
|
21
|
+
error: 3,
|
|
22
|
+
silent: 4,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const COLORS = {
|
|
26
|
+
debug: '\x1b[36m', // Cyan
|
|
27
|
+
info: '\x1b[32m', // Green
|
|
28
|
+
warn: '\x1b[33m', // Yellow
|
|
29
|
+
error: '\x1b[31m', // Red
|
|
30
|
+
reset: '\x1b[0m',
|
|
31
|
+
gray: '\x1b[90m',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL?.toLowerCase()] ?? LOG_LEVELS.info;
|
|
35
|
+
|
|
36
|
+
function timestamp() {
|
|
37
|
+
return new Date().toISOString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatArgs(args) {
|
|
41
|
+
return args.map(arg => {
|
|
42
|
+
if (arg instanceof Error) {
|
|
43
|
+
return arg.stack || arg.message;
|
|
44
|
+
}
|
|
45
|
+
if (typeof arg === 'object' && arg !== null) {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.stringify(arg);
|
|
48
|
+
} catch {
|
|
49
|
+
return String(arg);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return String(arg);
|
|
53
|
+
}).join(' ');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeLog(level, module, ...args) {
|
|
57
|
+
const levelNum = LOG_LEVELS[level];
|
|
58
|
+
if (levelNum < currentLevel) return;
|
|
59
|
+
|
|
60
|
+
const ts = timestamp();
|
|
61
|
+
const color = COLORS[level];
|
|
62
|
+
const reset = COLORS.reset;
|
|
63
|
+
const gray = COLORS.gray;
|
|
64
|
+
|
|
65
|
+
const prefix = `${gray}${ts}${reset} ${color}[${level.toUpperCase()}]${reset} [${module}]`;
|
|
66
|
+
const message = formatArgs(args);
|
|
67
|
+
|
|
68
|
+
const consoleFn = level === 'error' ? console.error :
|
|
69
|
+
level === 'warn' ? console.warn :
|
|
70
|
+
console.log;
|
|
71
|
+
|
|
72
|
+
consoleFn(`${prefix} ${message}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a logger instance for a module
|
|
77
|
+
* @param {string} moduleName
|
|
78
|
+
*/
|
|
79
|
+
export function createLogger(moduleName) {
|
|
80
|
+
return {
|
|
81
|
+
debug: (...args) => writeLog('debug', moduleName, ...args),
|
|
82
|
+
info: (...args) => writeLog('info', moduleName, ...args),
|
|
83
|
+
warn: (...args) => writeLog('warn', moduleName, ...args),
|
|
84
|
+
error: (...args) => writeLog('error', moduleName, ...args),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Global default logger */
|
|
89
|
+
export const logger = createLogger('app');
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Middleware - Optional Defense-in-Depth
|
|
3
|
+
*
|
|
4
|
+
* DESIGN NOTES (per OpenClaw docs):
|
|
5
|
+
* - OpenClaw is "loopback first" — auth is defense-in-depth, not mandatory for local deployments
|
|
6
|
+
* - Uplink is a STANDALONE app that connects to Gateway as an operator client
|
|
7
|
+
* - This middleware is OPT-IN via UPLINK_AUTH_ENABLED environment variable
|
|
8
|
+
* - When disabled (default), all routes work as today
|
|
9
|
+
* - When enabled, routes require valid Bearer token in Authorization header
|
|
10
|
+
*
|
|
11
|
+
* USAGE:
|
|
12
|
+
* import { requireAuth } from './middleware/auth.js';
|
|
13
|
+
* app.use('/api', requireAuth); // Protects all /api routes
|
|
14
|
+
*
|
|
15
|
+
* CONFIGURATION:
|
|
16
|
+
* UPLINK_AUTH_ENABLED=true Enable auth middleware
|
|
17
|
+
* UPLINK_AUTH_TOKEN=your-secret Required Bearer token (must be long/random)
|
|
18
|
+
*
|
|
19
|
+
* When auth is enabled:
|
|
20
|
+
* - HTTP requests must include: Authorization: Bearer <token>
|
|
21
|
+
* - WebSocket connections must include token in query param or first message
|
|
22
|
+
* - set_user command validates user matches authenticated identity
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import crypto from 'crypto';
|
|
26
|
+
import { log } from '../utils.js';
|
|
27
|
+
import { createLogger } from '../logger.js';
|
|
28
|
+
|
|
29
|
+
const authLog = createLogger('auth');
|
|
30
|
+
|
|
31
|
+
// Auth configuration from environment
|
|
32
|
+
const AUTH_ENABLED = process.env.UPLINK_AUTH_ENABLED === 'true';
|
|
33
|
+
const AUTH_TOKEN = process.env.UPLINK_AUTH_TOKEN || '';
|
|
34
|
+
|
|
35
|
+
// Minimum token length for security (prevent weak tokens)
|
|
36
|
+
const MIN_TOKEN_LENGTH = 32;
|
|
37
|
+
|
|
38
|
+
// Validate configuration on startup
|
|
39
|
+
if (AUTH_ENABLED) {
|
|
40
|
+
if (!AUTH_TOKEN) {
|
|
41
|
+
authLog.error('⚠️ UPLINK_AUTH_ENABLED=true but UPLINK_AUTH_TOKEN is not set!');
|
|
42
|
+
authLog.error('⚠️ Authentication will fail for all requests.');
|
|
43
|
+
authLog.error('⚠️ Set UPLINK_AUTH_TOKEN to a long random string (min 32 chars).');
|
|
44
|
+
} else if (AUTH_TOKEN.length < MIN_TOKEN_LENGTH) {
|
|
45
|
+
authLog.warn(`⚠️ UPLINK_AUTH_TOKEN is only ${AUTH_TOKEN.length} chars (min recommended: ${MIN_TOKEN_LENGTH})`);
|
|
46
|
+
authLog.warn('⚠️ Consider using a longer, more secure token.');
|
|
47
|
+
} else {
|
|
48
|
+
authLog.info('✅ Uplink authentication enabled');
|
|
49
|
+
authLog.info(`Token length: ${AUTH_TOKEN.length} characters`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract Bearer token from Authorization header
|
|
55
|
+
* @param {Request} req - Express request object
|
|
56
|
+
* @returns {string|null} - Extracted token or null if not found
|
|
57
|
+
*/
|
|
58
|
+
function extractBearerToken(req) {
|
|
59
|
+
const authHeader = req.headers.authorization;
|
|
60
|
+
if (!authHeader) return null;
|
|
61
|
+
|
|
62
|
+
if (authHeader.startsWith('Bearer ')) {
|
|
63
|
+
return authHeader.slice(7); // Remove 'Bearer ' prefix
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Verify if provided token matches configured token
|
|
71
|
+
* Uses constant-time comparison to prevent timing attacks
|
|
72
|
+
* @param {string} providedToken - Token from request
|
|
73
|
+
* @returns {boolean} - True if token is valid
|
|
74
|
+
*/
|
|
75
|
+
function verifyToken(providedToken) {
|
|
76
|
+
if (!providedToken || !AUTH_TOKEN) return false;
|
|
77
|
+
const providedBuf = Buffer.from(String(providedToken));
|
|
78
|
+
const expectedBuf = Buffer.from(AUTH_TOKEN);
|
|
79
|
+
if (providedBuf.length !== expectedBuf.length) return false;
|
|
80
|
+
return crypto.timingSafeEqual(providedBuf, expectedBuf);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Authentication middleware for HTTP routes
|
|
85
|
+
*
|
|
86
|
+
* When auth is disabled: passes through all requests
|
|
87
|
+
* When auth is enabled: requires valid Bearer token
|
|
88
|
+
*
|
|
89
|
+
* @param {Request} req - Express request
|
|
90
|
+
* @param {Response} res - Express response
|
|
91
|
+
* @param {Function} next - Next middleware
|
|
92
|
+
*/
|
|
93
|
+
export function requireAuth(req, res, next) {
|
|
94
|
+
// Auth disabled - passthrough
|
|
95
|
+
if (!AUTH_ENABLED) {
|
|
96
|
+
return next();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Extract and verify token
|
|
100
|
+
const token = extractBearerToken(req);
|
|
101
|
+
|
|
102
|
+
if (!token) {
|
|
103
|
+
log('warn', `[Auth] Unauthorized request to ${req.path} - no token provided`);
|
|
104
|
+
return res.status(401).json({
|
|
105
|
+
error: true,
|
|
106
|
+
message: 'Authentication required. Include Authorization: Bearer <token> header.',
|
|
107
|
+
code: 'UNAUTHORIZED'
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!verifyToken(token)) {
|
|
112
|
+
log('warn', `[Auth] Invalid token for ${req.path}`);
|
|
113
|
+
return res.status(401).json({
|
|
114
|
+
error: true,
|
|
115
|
+
message: 'Invalid authentication token.',
|
|
116
|
+
code: 'INVALID_TOKEN'
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Token valid - proceed
|
|
121
|
+
next();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Verify WebSocket token (from query param or upgrade request)
|
|
126
|
+
*
|
|
127
|
+
* @param {string|null} token - Token from query param or Authorization header
|
|
128
|
+
* @returns {boolean} - True if auth is disabled OR token is valid
|
|
129
|
+
*/
|
|
130
|
+
export function verifyWebSocketToken(token) {
|
|
131
|
+
// Auth disabled - allow all connections
|
|
132
|
+
if (!AUTH_ENABLED) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Auth enabled - require valid token
|
|
137
|
+
return verifyToken(token);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate set_user command when auth is enabled
|
|
142
|
+
* Prevents session impersonation by ensuring user can only set their own identity
|
|
143
|
+
*
|
|
144
|
+
* @param {string} requestedUser - User identity being set
|
|
145
|
+
* @param {string|null} authenticatedUser - User from auth token (if auth enabled)
|
|
146
|
+
* @returns {boolean} - True if allowed
|
|
147
|
+
*/
|
|
148
|
+
export function validateSetUser(requestedUser, authenticatedUser = null) {
|
|
149
|
+
// Auth disabled - allow any user (backward compatible)
|
|
150
|
+
if (!AUTH_ENABLED) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Auth enabled - must match authenticated identity
|
|
155
|
+
// For now, we derive authenticatedUser from session context
|
|
156
|
+
// In future, could embed user identity in JWT token
|
|
157
|
+
|
|
158
|
+
// TODO: When JWT tokens are implemented, decode token to get user identity
|
|
159
|
+
// For now, we simply require that auth is present (token was validated)
|
|
160
|
+
// and trust the user identity from session context
|
|
161
|
+
|
|
162
|
+
// This is a placeholder - full implementation would:
|
|
163
|
+
// 1. Use JWT tokens with embedded user claims
|
|
164
|
+
// 2. Validate requestedUser matches token.sub or token.user
|
|
165
|
+
|
|
166
|
+
return true; // Allow for now, will enhance with JWT in future
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if authentication is enabled
|
|
171
|
+
* Useful for conditional logic in other modules
|
|
172
|
+
*/
|
|
173
|
+
export function isAuthEnabled() {
|
|
174
|
+
return AUTH_ENABLED;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get redacted auth status for logging/debugging
|
|
179
|
+
*/
|
|
180
|
+
export function getAuthStatus() {
|
|
181
|
+
return {
|
|
182
|
+
enabled: AUTH_ENABLED,
|
|
183
|
+
tokenConfigured: AUTH_TOKEN.length > 0,
|
|
184
|
+
tokenLength: AUTH_TOKEN.length,
|
|
185
|
+
minTokenLength: MIN_TOKEN_LENGTH,
|
|
186
|
+
secure: AUTH_ENABLED && AUTH_TOKEN.length >= MIN_TOKEN_LENGTH
|
|
187
|
+
};
|
|
188
|
+
}
|